mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-03 17:59:29 +02:00
Merge branch 'dev' into feat/ip-filtering
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys";
|
||||
import { commandBarNavSections } from "@app/app/navigation";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: t("myVirtualApiKeysTitle")
|
||||
};
|
||||
}
|
||||
|
||||
type KeysPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KeysPage(props: KeysPageProps) {
|
||||
const params = await props.params;
|
||||
const orgId = params.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const cookieHeader = await authCookieHeader();
|
||||
|
||||
let overview: GetOrgOverviewResponse | undefined;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
|
||||
`/org/${orgId}/overview`,
|
||||
cookieHeader
|
||||
);
|
||||
overview = res.data.data;
|
||||
} catch {
|
||||
// leave undefined
|
||||
}
|
||||
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
cookieHeader
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch {
|
||||
// leave empty
|
||||
}
|
||||
|
||||
if (!orgs.some((org) => org.orgId === orgId)) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
let keysData: ListMyVirtualApiKeysResponse | null = null;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListMyVirtualApiKeysResponse>
|
||||
>(`/org/${orgId}/my-virtual-api-keys`, cookieHeader);
|
||||
keysData = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
if (!keysData) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const env = pullEnv();
|
||||
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
|
||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={[]}
|
||||
commandNavItems={commandBarNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
})}
|
||||
showSidebar={false}
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
>
|
||||
<UserVirtualApiKeys orgId={orgId} initialData={keysData} />
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys";
|
||||
import { commandBarNavSections } from "@app/app/navigation";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: t("myVirtualApiKeysTitle")
|
||||
};
|
||||
}
|
||||
|
||||
type ResourceKeysPageProps = {
|
||||
params: Promise<{ orgId: string; resourceGuid: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResourceKeysPage(props: ResourceKeysPageProps) {
|
||||
const params = await props.params;
|
||||
const orgId = params.orgId;
|
||||
const resourceGuid = params.resourceGuid;
|
||||
|
||||
if (!orgId || !resourceGuid) {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(
|
||||
`/auth/resource/${encodeURIComponent(resourceGuid)}?redirect=${encodeURIComponent(`/${orgId}/resource/${resourceGuid}/keys`)}`
|
||||
);
|
||||
}
|
||||
|
||||
const cookieHeader = await authCookieHeader();
|
||||
|
||||
let overview: GetOrgOverviewResponse | undefined;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
|
||||
`/org/${orgId}/overview`,
|
||||
cookieHeader
|
||||
);
|
||||
overview = res.data.data;
|
||||
} catch {
|
||||
// leave undefined
|
||||
}
|
||||
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
cookieHeader
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch {
|
||||
// leave empty
|
||||
}
|
||||
|
||||
if (!orgs.some((org) => org.orgId === orgId)) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
let keysData: ListMyVirtualApiKeysResponse | null = null;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListMyVirtualApiKeysResponse>
|
||||
>(
|
||||
`/org/${orgId}/my-virtual-api-keys?resourceGuid=${encodeURIComponent(resourceGuid)}`,
|
||||
cookieHeader
|
||||
);
|
||||
keysData = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}/keys`);
|
||||
}
|
||||
|
||||
if (!keysData) {
|
||||
redirect(`/${orgId}/keys`);
|
||||
}
|
||||
|
||||
const env = pullEnv();
|
||||
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
|
||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={[]}
|
||||
commandNavItems={commandBarNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
})}
|
||||
showSidebar={false}
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
>
|
||||
<UserVirtualApiKeys
|
||||
orgId={orgId}
|
||||
initialData={keysData}
|
||||
resourceNiceId={keysData.resourceNiceId ?? undefined}
|
||||
endpoint={keysData.resourceAccessUrl ?? undefined}
|
||||
/>
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -181,7 +181,7 @@ export default function NetworkingPage() {
|
||||
<SettingsSectionDescription>
|
||||
{t("remoteExitNodeNetworkingDescription")}
|
||||
<a
|
||||
href="https://docs.pangolin.net/placeholder"
|
||||
href="https://docs.pangolin.net/manage/remote-node/backhaul"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
|
||||
@@ -38,18 +38,6 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const accessControlsFormSchema = z.object({
|
||||
username: z.string(),
|
||||
autoProvisioned: z.boolean(),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
isAdmin: z.boolean().optional()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||
const { user: sessionUser } = useUserContext();
|
||||
@@ -69,6 +57,20 @@ export default function AccessControlsPage() {
|
||||
(build === "enterprise" && !isPaid) ||
|
||||
(build === "oss" && !isPaid));
|
||||
|
||||
const accessControlsFormSchema = z.object({
|
||||
username: z.string(),
|
||||
autoProvisioned: z.boolean(),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
isAdmin: z.boolean().optional()
|
||||
})
|
||||
)
|
||||
.min(1, { message: t("accessRoleSelectPlease") })
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(accessControlsFormSchema),
|
||||
defaultValues: {
|
||||
@@ -108,15 +110,6 @@ export default function AccessControlsPage() {
|
||||
async function executeSave() {
|
||||
const values = form.getValues();
|
||||
|
||||
if (values.roles.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
@@ -170,15 +163,6 @@ export default function AccessControlsPage() {
|
||||
|
||||
const values = form.getValues();
|
||||
|
||||
if (values.roles.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
|
||||
|
||||
const isRemovingOwnAdmin =
|
||||
|
||||
@@ -237,10 +237,13 @@ export default function Page() {
|
||||
return;
|
||||
}
|
||||
|
||||
const useOrgIdps =
|
||||
build === "saas" || env.app.identityProviderMode === "org";
|
||||
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListIdpsResponse>
|
||||
>(build === "saas" ? `/org/${orgId}/idp` : "/idp")
|
||||
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp")
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
@@ -301,8 +304,7 @@ export default function Page() {
|
||||
);
|
||||
const [isSubmittingExternal, setIsSubmittingExternal] = useState(false);
|
||||
|
||||
const loading =
|
||||
isSubmittingInternal || isSubmittingExternal;
|
||||
const loading = isSubmittingInternal || isSubmittingExternal;
|
||||
|
||||
async function onSubmitInternal() {
|
||||
const isValid = await internalForm.trigger();
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
createAiProviderFormSchema,
|
||||
toAiProviderAuthPayload,
|
||||
type AiProviderFormValues
|
||||
} from "@app/lib/aiProviderFormSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
authTypeRequiresApiKey,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderType
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export default function AiProviderAuthenticationPage() {
|
||||
const { provider, updateProvider } = useAiProviderContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
|
||||
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
|
||||
|
||||
const form = useForm<AiProviderFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: provider.name,
|
||||
type: provider.type as AiProviderType,
|
||||
upstreamUrl: provider.upstreamUrl ?? "",
|
||||
apiKey: provider.apiKey ?? "",
|
||||
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
|
||||
routingMode: (provider.routingMode as "url" | "target") ?? "url",
|
||||
skipTlsVerification: provider.skipTlsVerification,
|
||||
enabled: provider.enabled,
|
||||
capabilities: provider.capabilities ?? []
|
||||
}
|
||||
});
|
||||
|
||||
const authType = form.watch("authType");
|
||||
const showApiKey = authTypeRequiresApiKey(
|
||||
(authType as AiProviderAuthType | null) ?? "bearer"
|
||||
);
|
||||
|
||||
async function onSubmit(values: AiProviderFormValues) {
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<CreateOrEditAiProviderResponse>
|
||||
>(
|
||||
`/ai-provider/${provider.providerId}`,
|
||||
toAiProviderAuthPayload({
|
||||
...values,
|
||||
type: provider.type as AiProviderType
|
||||
})
|
||||
);
|
||||
const updated = res.data.data.provider;
|
||||
updateProvider(updated);
|
||||
form.reset({
|
||||
name: updated.name,
|
||||
type: updated.type as AiProviderType,
|
||||
upstreamUrl: updated.upstreamUrl ?? "",
|
||||
apiKey: updated.apiKey ?? "",
|
||||
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
|
||||
routingMode: (updated.routingMode as "url" | "target") ?? "url",
|
||||
skipTlsVerification: updated.skipTlsVerification,
|
||||
enabled: updated.enabled,
|
||||
capabilities: updated.capabilities ?? []
|
||||
});
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderUpdated")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorUpdate"),
|
||||
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
|
||||
});
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderAuthSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderAuthSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="ai-provider-auth-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderAuthType"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProviderAuthTypeSelect
|
||||
value={
|
||||
field.value ??
|
||||
"bearer"
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderAuthTypeDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showApiKey && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderApiKey"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderApiKeyDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
form="ai-provider-auth-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
import { BudgetsEditor } from "@app/components/BudgetsEditor";
|
||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AiProviderBudgetPage() {
|
||||
const { provider } = useAiProviderContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<BudgetsEditor
|
||||
orgId={provider.orgId}
|
||||
scope={{ type: "provider", id: provider.providerId }}
|
||||
title={t("aiProviderBudgetSettings")}
|
||||
description={t("aiProviderBudgetSettingsDescription")}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orgId: string; niceId: string }>;
|
||||
};
|
||||
|
||||
export default async function AiProviderConfigurationRedirect({
|
||||
params
|
||||
}: Props) {
|
||||
const { orgId, niceId } = await params;
|
||||
redirect(`/${orgId}/settings/ai-providers/${niceId}/network`);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function AiProviderGeneralPage() {
|
||||
const { provider, updateProvider } = useAiProviderContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
|
||||
const generalSchema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: t("nameRequired") }),
|
||||
niceId: z.string().min(1).max(255).optional(),
|
||||
enabled: z.boolean(),
|
||||
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.capabilities || data.capabilities.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("aiProviderErrorCapabilitiesRequired"),
|
||||
path: ["capabilities"]
|
||||
});
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
type GeneralFormValues = z.infer<typeof generalSchema>;
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(generalSchema),
|
||||
defaultValues: {
|
||||
name: provider.name,
|
||||
niceId: provider.niceId,
|
||||
enabled: provider.enabled,
|
||||
capabilities: provider.capabilities ?? []
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(values: GeneralFormValues) {
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const body: {
|
||||
name: string;
|
||||
niceId?: string;
|
||||
enabled: boolean;
|
||||
capabilities?: AiCapability[];
|
||||
} = {
|
||||
name: values.name.trim(),
|
||||
niceId: values.niceId,
|
||||
enabled: values.enabled,
|
||||
capabilities: values.capabilities ?? []
|
||||
};
|
||||
|
||||
const res = await api.post<
|
||||
AxiosResponse<CreateOrEditAiProviderResponse>
|
||||
>(`/ai-provider/${provider.providerId}`, body);
|
||||
const updated = res.data.data.provider;
|
||||
updateProvider(updated);
|
||||
form.reset({
|
||||
name: updated.name,
|
||||
niceId: updated.niceId,
|
||||
enabled: updated.enabled,
|
||||
capabilities: updated.capabilities ?? []
|
||||
});
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderUpdated")
|
||||
});
|
||||
|
||||
if (values.niceId && values.niceId !== provider.niceId) {
|
||||
router.replace(
|
||||
`/${provider.orgId}/settings/ai-providers/${values.niceId}/general`
|
||||
);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorUpdate"),
|
||||
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
|
||||
});
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="ai-provider-general-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="edit-enabled"
|
||||
label={t(
|
||||
"aiProviderEnabled"
|
||||
)}
|
||||
description={t(
|
||||
"aiProviderEnabledDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"enterIdentifier"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="capabilities"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderCapabilities"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProviderCapabilitiesSelect
|
||||
value={
|
||||
field.value ??
|
||||
[]
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderCapabilitiesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
form="ai-provider-general-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import AiProviderProvider from "@app/providers/AiProviderProvider";
|
||||
import type { GetOrgResponse } from "@server/routers/org";
|
||||
import type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Provider"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ orgId: string; niceId: string }>;
|
||||
};
|
||||
|
||||
export default async function AiProviderLayout({ children, params }: Props) {
|
||||
const { orgId, niceId } = await params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let provider = null;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetAiProviderResponse>>(
|
||||
`/org/${orgId}/ai-provider/${niceId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
provider = res.data.data.provider;
|
||||
} catch {
|
||||
redirect(`/${orgId}/settings/ai-providers`);
|
||||
}
|
||||
|
||||
if (!provider || provider.orgId !== orgId) {
|
||||
redirect(`/${orgId}/settings/ai-providers`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}/settings/ai-providers`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${orgId}/settings/ai-providers`);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: "/{orgId}/settings/ai-providers/{niceId}/general"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderNetworkSettings"),
|
||||
href: "/{orgId}/settings/ai-providers/{niceId}/network"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderModels"),
|
||||
href: "/{orgId}/settings/ai-providers/{niceId}/models"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderAuthSettings"),
|
||||
href: "/{orgId}/settings/ai-providers/{niceId}/authentication"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderBudgetSettings"),
|
||||
href: "/{orgId}/settings/ai-providers/{niceId}/budget"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("aiProviderSetting", {
|
||||
providerName: provider.name
|
||||
})}
|
||||
description={t("aiProviderSettingDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<AiProviderProvider provider={provider}>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</AiProviderProvider>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
persistPendingModelBudgets,
|
||||
type AiProviderModelListItem,
|
||||
type ModelListType
|
||||
} from "@app/components/AiProviderModelListEditor";
|
||||
import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { aiProviderQueries } from "@app/lib/queries";
|
||||
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
function toListItem(
|
||||
model: {
|
||||
modelId: number;
|
||||
modelKey: string;
|
||||
listType?: ModelListType | null;
|
||||
},
|
||||
listType: ModelListType
|
||||
): AiProviderModelListItem {
|
||||
return {
|
||||
clientId: String(model.modelId),
|
||||
modelId: model.modelId,
|
||||
modelKey: model.modelKey,
|
||||
listType,
|
||||
hasBudget: false
|
||||
};
|
||||
}
|
||||
|
||||
export default function AiProviderModelsPage() {
|
||||
const { provider } = useAiProviderContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations();
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [allowItems, setAllowItems] = useState<AiProviderModelListItem[]>([]);
|
||||
const [blockItems, setBlockItems] = useState<AiProviderModelListItem[]>([]);
|
||||
|
||||
const modelsQuery = useQuery(
|
||||
aiProviderQueries.providerModels({ providerId: provider.providerId })
|
||||
);
|
||||
const catalogQuery = useQuery(
|
||||
aiProviderQueries.catalogModels({ providerId: provider.providerId })
|
||||
);
|
||||
|
||||
const catalogModels = useMemo(
|
||||
() => (catalogQuery.data ?? []).map((entry) => entry.model),
|
||||
[catalogQuery.data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelsQuery.data) return;
|
||||
setAllowItems(
|
||||
modelsQuery.data
|
||||
.filter((model) => (model.listType ?? "allow") === "allow")
|
||||
.map((model) => toListItem(model, "allow"))
|
||||
);
|
||||
setBlockItems(
|
||||
modelsQuery.data
|
||||
.filter((model) => model.listType === "block")
|
||||
.map((model) => toListItem(model, "block"))
|
||||
);
|
||||
}, [modelsQuery.data]);
|
||||
|
||||
async function onSave() {
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const existing = modelsQuery.data ?? [];
|
||||
const existingById = new Map(
|
||||
existing.map((model) => [model.modelId, model])
|
||||
);
|
||||
const existingByKey = new Map(
|
||||
existing.map((model) => [model.modelKey, model])
|
||||
);
|
||||
|
||||
const desiredItems = [...allowItems, ...blockItems].map((item) => ({
|
||||
...item,
|
||||
modelKey: item.modelKey.trim()
|
||||
}));
|
||||
|
||||
const nextAllow = new Set(
|
||||
allowItems.map((item) => item.modelKey.trim()).filter(Boolean)
|
||||
);
|
||||
const nextBlock = new Set(
|
||||
blockItems.map((item) => item.modelKey.trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
|
||||
if (overlap.length > 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderModelsErrorUpdate"),
|
||||
description: t("aiProviderModelsOverlapError", {
|
||||
keys: overlap.join(", ")
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toCreate: AiProviderModelListItem[] = [];
|
||||
const toUpdate: {
|
||||
modelId: number;
|
||||
modelKey: string;
|
||||
listType: ModelListType;
|
||||
}[] = [];
|
||||
const retainedIds = new Set<number>();
|
||||
|
||||
for (const item of desiredItems) {
|
||||
const listType = item.listType;
|
||||
const modelKey = item.modelKey;
|
||||
if (!modelKey) continue;
|
||||
|
||||
if (item.modelId != null && existingById.has(item.modelId)) {
|
||||
retainedIds.add(item.modelId);
|
||||
const existingModel = existingById.get(item.modelId)!;
|
||||
if (
|
||||
existingModel.modelKey !== modelKey ||
|
||||
(existingModel.listType ?? "allow") !== listType
|
||||
) {
|
||||
toUpdate.push({
|
||||
modelId: item.modelId,
|
||||
modelKey,
|
||||
listType
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingBySameKey = existingByKey.get(modelKey);
|
||||
if (
|
||||
existingBySameKey &&
|
||||
!retainedIds.has(existingBySameKey.modelId)
|
||||
) {
|
||||
retainedIds.add(existingBySameKey.modelId);
|
||||
if ((existingBySameKey.listType ?? "allow") !== listType) {
|
||||
toUpdate.push({
|
||||
modelId: existingBySameKey.modelId,
|
||||
modelKey,
|
||||
listType
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
toCreate.push(item);
|
||||
}
|
||||
|
||||
const toDelete = existing
|
||||
.filter((model) => !retainedIds.has(model.modelId))
|
||||
.map((model) => model.modelId);
|
||||
|
||||
await Promise.all([
|
||||
...toCreate.map(async (item) => {
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateOrEditAiModelResponse>
|
||||
>(`/ai-provider/${provider.providerId}/model`, {
|
||||
modelKey: item.modelKey,
|
||||
name: item.modelKey,
|
||||
listType: item.listType
|
||||
});
|
||||
await persistPendingModelBudgets({
|
||||
api,
|
||||
orgId: provider.orgId,
|
||||
modelId: res.data.data.model.modelId,
|
||||
pendingBudgets: item.pendingBudgets
|
||||
});
|
||||
}),
|
||||
...toUpdate.map(({ modelId, modelKey, listType }) =>
|
||||
api.post(`/ai-model/${modelId}`, {
|
||||
modelKey,
|
||||
name: modelKey,
|
||||
listType
|
||||
})
|
||||
),
|
||||
...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`))
|
||||
]);
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
aiProviderQueries.providerModels({
|
||||
providerId: provider.providerId
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderModelsUpdated")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderModelsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("aiProviderModelsErrorUpdate")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderModels")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderModelsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<AiProviderModelsLists
|
||||
orgId={provider.orgId}
|
||||
allowItems={allowItems}
|
||||
onAllowChange={setAllowItems}
|
||||
blockItems={blockItems}
|
||||
onBlockChange={setBlockItems}
|
||||
catalogModels={catalogModels}
|
||||
disabled={modelsQuery.isLoading}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="button"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading || modelsQuery.isLoading}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ProxyResourceTargetsForm,
|
||||
type ProxyResourceTargetsFormHandle
|
||||
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
createAiProviderFormSchema,
|
||||
showsUpstreamUrlField,
|
||||
toAiProviderNetworkPayload,
|
||||
type AiProviderFormValues
|
||||
} from "@app/lib/aiProviderFormSchema";
|
||||
import { aiProviderQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type {
|
||||
AiProviderAuthType,
|
||||
AiProviderType
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export default function AiProviderNetworkPage() {
|
||||
const { provider, updateProvider } = useAiProviderContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const params = useParams();
|
||||
const orgId = params.orgId as string;
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null);
|
||||
|
||||
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
|
||||
|
||||
const form = useForm<AiProviderFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: provider.name,
|
||||
type: provider.type as AiProviderType,
|
||||
upstreamUrl: provider.upstreamUrl ?? "",
|
||||
apiKey: "",
|
||||
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
|
||||
routingMode: (provider.routingMode as "url" | "target") ?? "url",
|
||||
headers: provider.headers ?? [],
|
||||
skipTlsVerification: provider.skipTlsVerification,
|
||||
enabled: provider.enabled,
|
||||
capabilities: provider.capabilities ?? []
|
||||
}
|
||||
});
|
||||
|
||||
const providerType = form.watch("type");
|
||||
const routingMode = form.watch("routingMode");
|
||||
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
|
||||
const showRoutingMode = providerType === "custom";
|
||||
const isTargetModeSelected = routingMode === "target";
|
||||
const isTargetModeSaved =
|
||||
provider.type === "custom" && provider.routingMode === "target";
|
||||
const showTargetsForm = showRoutingMode && isTargetModeSelected;
|
||||
|
||||
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery({
|
||||
...aiProviderQueries.providerTargets({
|
||||
providerId: provider.providerId
|
||||
}),
|
||||
enabled: isTargetModeSaved
|
||||
});
|
||||
|
||||
async function onSubmit(values: AiProviderFormValues) {
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<CreateOrEditAiProviderResponse>
|
||||
>(
|
||||
`/ai-provider/${provider.providerId}`,
|
||||
toAiProviderNetworkPayload({
|
||||
...values,
|
||||
type: provider.type as AiProviderType
|
||||
})
|
||||
);
|
||||
const updated = res.data.data.provider;
|
||||
updateProvider(updated);
|
||||
form.reset({
|
||||
name: updated.name,
|
||||
type: updated.type as AiProviderType,
|
||||
upstreamUrl: updated.upstreamUrl ?? "",
|
||||
apiKey: "",
|
||||
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
|
||||
routingMode: (updated.routingMode as "url" | "target") ?? "url",
|
||||
headers: updated.headers ?? [],
|
||||
skipTlsVerification: updated.skipTlsVerification,
|
||||
enabled: updated.enabled,
|
||||
capabilities: updated.capabilities ?? []
|
||||
});
|
||||
|
||||
if (values.routingMode === "target" && targetsFormRef.current) {
|
||||
const targetsSaved = await targetsFormRef.current.save({
|
||||
silent: true
|
||||
});
|
||||
if (!targetsSaved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderUpdated")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorUpdate"),
|
||||
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
|
||||
});
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderNetworkSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderNetworkSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="ai-provider-network-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
{showRoutingMode && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routingMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderRoutingMode"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<StrategySelect
|
||||
cols={2}
|
||||
options={[
|
||||
{
|
||||
id: "url",
|
||||
title: t(
|
||||
"aiProviderRoutingModeUrl"
|
||||
),
|
||||
description:
|
||||
t(
|
||||
"aiProviderRoutingModeUrlDescription"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
title: t(
|
||||
"aiProviderRoutingModeTarget"
|
||||
),
|
||||
description:
|
||||
t(
|
||||
"aiProviderRoutingModeTargetDescription"
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={
|
||||
field.value ??
|
||||
"url"
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skipTlsVerification"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="edit-skip-tls"
|
||||
label={t(
|
||||
"aiProviderSkipTlsVerification"
|
||||
)}
|
||||
description={t(
|
||||
"aiProviderSkipTlsVerificationDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value ??
|
||||
false
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showUpstream && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="upstreamUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderUpstreamUrl"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderUpstreamUrlDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="headers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("customHeaders")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={field.value}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
onValidityChange={
|
||||
setHeadersValid
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderCustomHeadersDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
{showTargetsForm &&
|
||||
(!isTargetModeSaved || !isLoadingTargets) && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("targets")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
<ProxyResourceTargetsForm
|
||||
ref={targetsFormRef}
|
||||
orgId={orgId}
|
||||
isHttp
|
||||
isAiProvider
|
||||
providerId={provider.providerId}
|
||||
initialTargets={
|
||||
isTargetModeSaved ? remoteTargets : []
|
||||
}
|
||||
allowedMethods={["http", "https"]}
|
||||
emptyMessage={t("aiProviderTargetNoOne")}
|
||||
embedded
|
||||
hideSaveButton
|
||||
disableAdvancedMode
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading || !headersValid}
|
||||
form="ai-provider-network-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orgId: string; niceId: string }>;
|
||||
};
|
||||
|
||||
export default async function AiProviderPage({ params }: Props) {
|
||||
const { orgId, niceId } = await params;
|
||||
redirect(`/${orgId}/settings/ai-providers/${niceId}/general`);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create AI Provider"
|
||||
};
|
||||
|
||||
export default function CreateAiProviderLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ProxyResourceTargetsForm,
|
||||
type LocalTarget
|
||||
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
|
||||
import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
|
||||
import {
|
||||
persistPendingModelBudgets,
|
||||
type AiProviderModelListItem
|
||||
} from "@app/components/AiProviderModelListEditor";
|
||||
import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists";
|
||||
import {
|
||||
AiProviderTypeSelect,
|
||||
aiProviderTypeLabelMap
|
||||
} from "@app/components/AiProviderTypeSelect";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
createAiProviderCreateFormSchema,
|
||||
defaultAuthTypeForProvider,
|
||||
defaultCapabilitiesForProvider,
|
||||
emptyUpstreamForType,
|
||||
showsUpstreamUrlField,
|
||||
toAiProviderCreatePayload,
|
||||
type AiProviderFormValues
|
||||
} from "@app/lib/aiProviderFormSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults";
|
||||
import { aiProviderQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
CreateOrEditAiModelResponse,
|
||||
CreateOrEditAiProviderResponse
|
||||
} from "@server/routers/aiProvider/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export default function CreateAiProviderPage() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const params = useParams();
|
||||
const orgId = params.orgId as string;
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
const [allowItems, setAllowItems] = useState<AiProviderModelListItem[]>([]);
|
||||
const [blockItems, setBlockItems] = useState<AiProviderModelListItem[]>([]);
|
||||
const targetsRef = useRef<LocalTarget[]>([]);
|
||||
|
||||
const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]);
|
||||
|
||||
const form = useForm<AiProviderFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: t(aiProviderTypeLabelMap.openai),
|
||||
type: "openai",
|
||||
upstreamUrl: emptyUpstreamForType("openai"),
|
||||
apiKey: "",
|
||||
authType: defaultAuthTypeForProvider("openai"),
|
||||
routingMode: "url",
|
||||
capabilities: defaultCapabilitiesForProvider("openai"),
|
||||
headers: [],
|
||||
skipTlsVerification: false,
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
|
||||
const providerType = form.watch("type");
|
||||
const routingMode = form.watch("routingMode");
|
||||
const authType = form.watch("authType");
|
||||
|
||||
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
|
||||
const showRoutingMode = providerType === "custom";
|
||||
const showTargets = providerType === "custom" && routingMode === "target";
|
||||
const showApiKey = authTypeRequiresApiKey(authType ?? "bearer");
|
||||
|
||||
const catalogQuery = useQuery(
|
||||
aiProviderQueries.catalogModelsByType({
|
||||
orgId,
|
||||
type: providerType
|
||||
})
|
||||
);
|
||||
const catalogModels = useMemo(
|
||||
() => (catalogQuery.data ?? []).map((entry) => entry.model),
|
||||
[catalogQuery.data]
|
||||
);
|
||||
|
||||
async function createTargets(
|
||||
providerId: number,
|
||||
localTargets: LocalTarget[]
|
||||
) {
|
||||
for (const target of localTargets) {
|
||||
const data = {
|
||||
ip: target.ip,
|
||||
port: target.port,
|
||||
method: target.method,
|
||||
enabled: target.enabled,
|
||||
siteId: target.siteId,
|
||||
hcEnabled: target.hcEnabled,
|
||||
hcPath: target.hcPath || null,
|
||||
hcMethod: target.hcMethod || null,
|
||||
hcInterval: target.hcInterval || null,
|
||||
hcTimeout: target.hcTimeout || null,
|
||||
hcHeaders: target.hcHeaders || null,
|
||||
hcScheme: target.hcScheme || null,
|
||||
hcHostname: target.hcHostname || null,
|
||||
hcPort: target.hcPort || null,
|
||||
hcFollowRedirects: target.hcFollowRedirects || null,
|
||||
hcStatus: target.hcStatus || null,
|
||||
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
|
||||
hcMode: target.hcMode || null,
|
||||
hcTlsServerName: target.hcTlsServerName,
|
||||
hcHealthyThreshold: target.hcHealthyThreshold || null,
|
||||
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null,
|
||||
path: target.path,
|
||||
pathMatchType: target.pathMatchType,
|
||||
rewritePath: target.rewritePath,
|
||||
rewritePathType: target.rewritePathType,
|
||||
priority: target.priority
|
||||
};
|
||||
await api.put(`/ai-provider/${providerId}/target`, data);
|
||||
}
|
||||
}
|
||||
|
||||
async function createModels(
|
||||
providerId: number,
|
||||
items: AiProviderModelListItem[]
|
||||
) {
|
||||
for (const item of items) {
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateOrEditAiModelResponse>
|
||||
>(`/ai-provider/${providerId}/model`, {
|
||||
modelKey: item.modelKey,
|
||||
name: item.modelKey,
|
||||
listType: item.listType
|
||||
});
|
||||
await persistPendingModelBudgets({
|
||||
api,
|
||||
orgId,
|
||||
modelId: res.data.data.model.modelId,
|
||||
pendingBudgets: item.pendingBudgets
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(values: AiProviderFormValues) {
|
||||
const targets = targetsRef.current;
|
||||
|
||||
if (showTargets) {
|
||||
const invalidTargets = targets.filter(
|
||||
(target) =>
|
||||
!target.ip ||
|
||||
target.ip.trim() === "" ||
|
||||
!target.port ||
|
||||
target.port <= 0 ||
|
||||
isNaN(target.port)
|
||||
);
|
||||
if (invalidTargets.length > 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("targetErrorInvalidIp"),
|
||||
description: t("targetErrorInvalidIpDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextAllow = new Set(
|
||||
allowItems.map((item) => item.modelKey.trim()).filter(Boolean)
|
||||
);
|
||||
const nextBlock = new Set(
|
||||
blockItems.map((item) => item.modelKey.trim()).filter(Boolean)
|
||||
);
|
||||
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
|
||||
if (overlap.length > 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorCreate"),
|
||||
description: t("aiProviderModelsOverlapError", {
|
||||
keys: overlap.join(", ")
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const modelItems = [...allowItems, ...blockItems]
|
||||
.map((item) => ({
|
||||
...item,
|
||||
modelKey: item.modelKey.trim()
|
||||
}))
|
||||
.filter((item) => item.modelKey);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateOrEditAiProviderResponse>
|
||||
>(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values));
|
||||
|
||||
const providerId = res.data.data.provider.providerId;
|
||||
const niceId = res.data.data.provider.niceId;
|
||||
|
||||
if (showTargets && targets.length > 0) {
|
||||
try {
|
||||
await createTargets(providerId, targets);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("aiProviderErrorCreate")
|
||||
)
|
||||
});
|
||||
router.push(
|
||||
`/${orgId}/settings/ai-providers/${niceId}/network`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (modelItems.length > 0) {
|
||||
try {
|
||||
await createModels(providerId, modelItems);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("aiProviderErrorCreate")
|
||||
)
|
||||
});
|
||||
router.push(
|
||||
`/${orgId}/settings/ai-providers/${niceId}/models`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderCreated")
|
||||
});
|
||||
|
||||
router.push(`/${orgId}/settings/ai-providers/${niceId}`);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorCreate"),
|
||||
description: formatAxiosError(e, t("aiProviderErrorCreate"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title={t("aiProviderCreate")}
|
||||
description={t("aiProviderCreateDescription")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(`/${orgId}/settings/ai-providers`)
|
||||
}
|
||||
>
|
||||
{t("aiProviderSeeAll")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("aiProviderType")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProviderTypeSelect
|
||||
value={field.value}
|
||||
onChange={(
|
||||
value
|
||||
) => {
|
||||
const previousType =
|
||||
field.value;
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
setAllowItems(
|
||||
[]
|
||||
);
|
||||
setBlockItems(
|
||||
[]
|
||||
);
|
||||
form.setValue(
|
||||
"upstreamUrl",
|
||||
emptyUpstreamForType(
|
||||
value
|
||||
)
|
||||
);
|
||||
form.setValue(
|
||||
"authType",
|
||||
defaultAuthTypeForProvider(
|
||||
value
|
||||
)
|
||||
);
|
||||
form.setValue(
|
||||
"capabilities",
|
||||
defaultCapabilitiesForProvider(
|
||||
value
|
||||
)
|
||||
);
|
||||
const currentName =
|
||||
form.getValues(
|
||||
"name"
|
||||
);
|
||||
if (
|
||||
value !==
|
||||
"custom"
|
||||
) {
|
||||
const previousLabel =
|
||||
t(
|
||||
aiProviderTypeLabelMap[
|
||||
previousType
|
||||
]
|
||||
);
|
||||
if (
|
||||
!currentName.trim() ||
|
||||
currentName ===
|
||||
previousLabel
|
||||
) {
|
||||
form.setValue(
|
||||
"name",
|
||||
t(
|
||||
aiProviderTypeLabelMap[
|
||||
value
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
form.setValue(
|
||||
"routingMode",
|
||||
"url"
|
||||
);
|
||||
targetsRef.current =
|
||||
[];
|
||||
} else {
|
||||
const isDefaultName =
|
||||
Object.entries(
|
||||
aiProviderTypeLabelMap
|
||||
).some(
|
||||
([
|
||||
type,
|
||||
key
|
||||
]) =>
|
||||
type !==
|
||||
"custom" &&
|
||||
currentName ===
|
||||
t(
|
||||
key
|
||||
)
|
||||
);
|
||||
if (
|
||||
isDefaultName
|
||||
) {
|
||||
form.setValue(
|
||||
"name",
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="capabilities"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderCapabilities"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProviderCapabilitiesSelect
|
||||
value={
|
||||
field.value ??
|
||||
[]
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderCapabilitiesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderNetworkSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderNetworkSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
{showRoutingMode && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routingMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderRoutingMode"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<StrategySelect
|
||||
cols={2}
|
||||
options={[
|
||||
{
|
||||
id: "url",
|
||||
title: t(
|
||||
"aiProviderRoutingModeUrl"
|
||||
),
|
||||
description:
|
||||
t(
|
||||
"aiProviderRoutingModeUrlDescription"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
title: t(
|
||||
"aiProviderRoutingModeTarget"
|
||||
),
|
||||
description:
|
||||
t(
|
||||
"aiProviderRoutingModeTargetDescription"
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={
|
||||
field.value ??
|
||||
"url"
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
if (
|
||||
value !==
|
||||
"target"
|
||||
) {
|
||||
targetsRef.current =
|
||||
[];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skipTlsVerification"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="skip-tls"
|
||||
label={t(
|
||||
"aiProviderSkipTlsVerification"
|
||||
)}
|
||||
description={t(
|
||||
"aiProviderSkipTlsVerificationDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value ??
|
||||
false
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showUpstream && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="upstreamUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderUpstreamUrl"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderUpstreamUrlDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="headers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("customHeaders")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={field.value}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
onValidityChange={
|
||||
setHeadersValid
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderCustomHeadersDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
|
||||
{showTargets && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("targets")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
<ProxyResourceTargetsForm
|
||||
orgId={orgId}
|
||||
isHttp
|
||||
isAiProvider
|
||||
onChange={(nextTargets) => {
|
||||
targetsRef.current = nextTargets;
|
||||
}}
|
||||
allowedMethods={["http", "https"]}
|
||||
emptyMessage={t(
|
||||
"aiProviderTargetNoOne"
|
||||
)}
|
||||
embedded
|
||||
hideSaveButton
|
||||
disableAdvancedMode
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderAuthSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderAuthSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderAuthType"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProviderAuthTypeSelect
|
||||
value={
|
||||
field.value ??
|
||||
"bearer"
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderAuthTypeDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showApiKey && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderApiKey"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiProviderApiKeyDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiProviderModels")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiProviderCreateModelsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<AiProviderModelsLists
|
||||
orgId={orgId}
|
||||
allowItems={allowItems}
|
||||
onAllowChange={setAllowItems}
|
||||
blockItems={blockItems}
|
||||
onBlockChange={setBlockItems}
|
||||
catalogModels={catalogModels}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(`/${orgId}/settings/ai-providers`)
|
||||
}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading || !headersValid}
|
||||
onClick={() => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import AiProvidersBanner from "@app/components/AiProvidersBanner";
|
||||
import AiProvidersTable from "@app/components/AiProvidersTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Providers"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export default async function AiProvidersPage({ params, searchParams }: Props) {
|
||||
const { orgId } = await params;
|
||||
const searchParamsObj = new URLSearchParams(await searchParams);
|
||||
const t = await getTranslations();
|
||||
|
||||
let providers: ListAiProvidersResponse["providers"] = [];
|
||||
let pagination: ListAiProvidersResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListAiProvidersResponse>>(
|
||||
`/org/${orgId}/ai-providers?${searchParamsObj.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
providers = responseData.providers;
|
||||
pagination = responseData.pagination;
|
||||
} catch {
|
||||
// empty list on error
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("aiProvidersTitle")}
|
||||
description={t("aiProvidersDescription")}
|
||||
/>
|
||||
|
||||
<AiProvidersBanner />
|
||||
|
||||
<AiProvidersTable
|
||||
orgId={orgId}
|
||||
providers={providers.map((provider) => ({
|
||||
providerId: provider.providerId,
|
||||
niceId: provider.niceId,
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
routingMode: provider.routingMode,
|
||||
enabled: provider.enabled,
|
||||
effectiveUpstreamUrl: provider.effectiveUpstreamUrl,
|
||||
apiKeyLastChars: provider.apiKeyLastChars
|
||||
}))}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
archived: Boolean(client.archived),
|
||||
blocked: Boolean(client.blocked),
|
||||
approvalState: client.approvalState,
|
||||
fingerprint
|
||||
fingerprint,
|
||||
firstSeen: client.firstSeen ?? null,
|
||||
lastSeen: client.lastSeen ?? null
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ const SecurityFormSchema = z.object({
|
||||
settingsLogRetentionDaysRequest: z.number(),
|
||||
settingsLogRetentionDaysAccess: z.number(),
|
||||
settingsLogRetentionDaysAction: z.number(),
|
||||
settingsLogRetentionDaysConnection: z.number()
|
||||
settingsLogRetentionDaysConnection: z.number(),
|
||||
settingsLogRetentionDaysAISessions: z.number()
|
||||
});
|
||||
|
||||
const LOG_RETENTION_OPTIONS = [
|
||||
@@ -122,7 +123,8 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
settingsLogRetentionDaysRequest: true,
|
||||
settingsLogRetentionDaysAccess: true,
|
||||
settingsLogRetentionDaysAction: true,
|
||||
settingsLogRetentionDaysConnection: true
|
||||
settingsLogRetentionDaysConnection: true,
|
||||
settingsLogRetentionDaysAISessions: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
@@ -133,7 +135,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
settingsLogRetentionDaysAction:
|
||||
org.settingsLogRetentionDaysAction ?? 15,
|
||||
settingsLogRetentionDaysConnection:
|
||||
org.settingsLogRetentionDaysConnection ?? 15
|
||||
org.settingsLogRetentionDaysConnection ?? 15,
|
||||
settingsLogRetentionDaysAISessions:
|
||||
org.settingsLogRetentionDaysAISessions ?? 15
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
@@ -161,7 +165,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
settingsLogRetentionDaysAction:
|
||||
data.settingsLogRetentionDaysAction,
|
||||
settingsLogRetentionDaysConnection:
|
||||
data.settingsLogRetentionDaysConnection
|
||||
data.settingsLogRetentionDaysConnection,
|
||||
settingsLogRetentionDaysAISessions:
|
||||
data.settingsLogRetentionDaysAISessions
|
||||
} as any;
|
||||
|
||||
// Update organization
|
||||
@@ -673,6 +679,131 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAISessions"
|
||||
render={({ field }) => {
|
||||
const isDisabled = !isPaidUser(
|
||||
tierMatrix.aiSessionLogs
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionAISessionsLabel"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
field.onChange(
|
||||
parseInt(
|
||||
value,
|
||||
10
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectLogRetention"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.filter(
|
||||
(
|
||||
option
|
||||
) => {
|
||||
if (
|
||||
build !=
|
||||
"saas"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let maxDays: number;
|
||||
|
||||
if (
|
||||
!subscriptionTier
|
||||
) {
|
||||
// No tier
|
||||
maxDays = 3;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"enterprise"
|
||||
) {
|
||||
// Enterprise - no limit
|
||||
return true;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier3"
|
||||
) {
|
||||
maxDays = 90;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier2"
|
||||
) {
|
||||
maxDays = 30;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier1"
|
||||
) {
|
||||
maxDays = 7;
|
||||
} else {
|
||||
// Default to most restrictive
|
||||
maxDays = 3;
|
||||
}
|
||||
|
||||
// Filter out options that exceed the max
|
||||
// Special values: -1 (forever) and 9001 (end of year) should be filtered
|
||||
if (
|
||||
option.value <
|
||||
0 ||
|
||||
option.value >
|
||||
maxDays
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
).map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -80,7 +80,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
orgId={params.orgId}
|
||||
orgs={orgs}
|
||||
navItems={orgNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
isPrimaryOrg: primaryOrg,
|
||||
isServerAdmin: user.serverAdmin
|
||||
})}
|
||||
commandNavItems={commandBarNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
|
||||
@@ -20,6 +20,8 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
|
||||
import axios from "axios";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
@@ -33,6 +35,7 @@ export default function GeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { orgId } = useParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
@@ -152,6 +155,23 @@ export default function GeneralPage() {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
// When the end date has no explicit time, it represents an
|
||||
// open-ended "up to now" upper bound. Since dateRange is only
|
||||
// recomputed on user interaction, that upper bound otherwise stays
|
||||
// frozen at whenever the page first loaded, so refreshing would
|
||||
// never surface logs created since then. Bump it to the current
|
||||
// time so the query key changes and refetches the latest window.
|
||||
if (dateRange.endDate?.date && !dateRange.endDate.time) {
|
||||
setDateRange((prev) => ({
|
||||
...prev,
|
||||
endDate: { date: new Date() }
|
||||
}));
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
@@ -308,7 +328,14 @@ export default function GeneralPage() {
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return row.original.ip ? (
|
||||
row.original.ip
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
@@ -371,6 +398,14 @@ export default function GeneralPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (
|
||||
!row.original.resourceNiceId ||
|
||||
!row.original.resourceName
|
||||
) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={
|
||||
@@ -420,14 +455,19 @@ export default function GeneralPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const typeLabel =
|
||||
row.original.type === "ssh" ||
|
||||
row.original.type === "rdp" ||
|
||||
row.original.type === "vnc"
|
||||
const typeLabel = row.original.type
|
||||
? row.original.type === "ssh" ||
|
||||
row.original.type === "rdp" ||
|
||||
row.original.type === "vnc"
|
||||
? row.original.type.toUpperCase()
|
||||
: row.original.type.charAt(0).toUpperCase() +
|
||||
row.original.type.slice(1);
|
||||
return <span>{typeLabel || "-"}</span>;
|
||||
row.original.type.slice(1)
|
||||
: null;
|
||||
return typeLabel ? (
|
||||
<span>{typeLabel}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -464,7 +504,9 @@ export default function GeneralPage() {
|
||||
{row.original.actor}
|
||||
</>
|
||||
) : (
|
||||
<>-</>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -475,7 +517,9 @@ export default function GeneralPage() {
|
||||
header: () => <span className="px-2">{t("actorId")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
{row.original.actorId || (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -519,11 +563,18 @@ export default function GeneralPage() {
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.accessLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAccess === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("accessLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("accessLogs")}
|
||||
onRefresh={() => refetch()}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -29,6 +31,7 @@ export default function GeneralPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
@@ -135,6 +138,23 @@ export default function GeneralPage() {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
// When the end date has no explicit time, it represents an
|
||||
// open-ended "up to now" upper bound. Since dateRange is only
|
||||
// recomputed on user interaction, that upper bound otherwise stays
|
||||
// frozen at whenever the page first loaded, so refreshing would
|
||||
// never surface logs created since then. Bump it to the current
|
||||
// time so the query key changes and refetches the latest window.
|
||||
if (dateRange.endDate?.date && !dateRange.endDate.time) {
|
||||
setDateRange((prev) => ({
|
||||
...prev,
|
||||
endDate: { date: new Date() }
|
||||
}));
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
@@ -286,7 +306,11 @@ export default function GeneralPage() {
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.actor}
|
||||
{row.original.actor || (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -297,7 +321,11 @@ export default function GeneralPage() {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId}
|
||||
{row.original.actorId || (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -334,13 +362,20 @@ export default function GeneralPage() {
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.actionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAction === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("actionLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("actionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="action"
|
||||
onRefresh={() => refetch()}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AiUsageAnalyticsData } from "@app/components/AiUsageAnalyticsData";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: t("aiUsageAnalyticsTitle")
|
||||
};
|
||||
}
|
||||
|
||||
export interface AiUsageAnalyticsPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function AiUsageAnalyticsPage(
|
||||
props: AiUsageAnalyticsPageProps
|
||||
) {
|
||||
const orgId = (await props.params).orgId;
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("aiUsageAnalyticsTitle")}
|
||||
description={t("aiUsageAnalyticsDescription")}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<AiUsageAnalyticsData orgId={orgId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Session Logs"
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
"use client";
|
||||
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { AiSessionChatView } from "@app/components/AiSessionChatView";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { ArrowUpRight, Bot, Waves, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import type { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
|
||||
|
||||
const capabilityLabels: Record<string, string> = {
|
||||
openai_chat: "OpenAI Chat Completions",
|
||||
openai_responses: "OpenAI Responses",
|
||||
anthropic_messages: "Anthropic Messages",
|
||||
v1_models: "Models List",
|
||||
gemini_generate_content: "Gemini",
|
||||
google_generate_content: "Vertex AI (Generate Content)",
|
||||
google_raw_predict: "Vertex AI (Raw Predict)",
|
||||
bedrock_model_invoke: "Bedrock (Invoke Model)",
|
||||
bedrock_converse: "Bedrock (Converse)"
|
||||
};
|
||||
|
||||
export default function AiSessionLogsPage() {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [pageSize, setPageSize] = useStoredPageSize("ai-session-logs", 20);
|
||||
|
||||
const [filters, setFilters] = useState<{
|
||||
providerId?: string;
|
||||
capability?: string;
|
||||
resourceId?: string;
|
||||
actor?: string;
|
||||
virtualApiKeyId?: string;
|
||||
model?: string;
|
||||
isStream?: string;
|
||||
}>({
|
||||
providerId: searchParams.get("providerId") || undefined,
|
||||
capability: searchParams.get("capability") || undefined,
|
||||
resourceId: searchParams.get("resourceId") || undefined,
|
||||
actor: searchParams.get("actor") || undefined,
|
||||
virtualApiKeyId: searchParams.get("virtualApiKeyId") || undefined,
|
||||
model: searchParams.get("model") || undefined,
|
||||
isStream: searchParams.get("isStream") || undefined
|
||||
});
|
||||
|
||||
const getDefaultDateRange = () => {
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
};
|
||||
}
|
||||
return {
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
if (dateRange.startDate?.date) {
|
||||
const dt = new Date(dateRange.startDate.date);
|
||||
if (dateRange.startDate.time) {
|
||||
const [h, m, s] = dateRange.startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
}
|
||||
timeStart = dt.toISOString();
|
||||
}
|
||||
|
||||
if (dateRange.endDate?.date) {
|
||||
const dt = new Date(dateRange.endDate.date);
|
||||
if (dateRange.endDate.time) {
|
||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
} else {
|
||||
const now = new Date();
|
||||
dt.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
timeEnd = dt.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...filters
|
||||
};
|
||||
}, [dateRange, currentPage, pageSize, filters]);
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
...logQueries.aiSessions({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
||||
const totalCount = data?.pagination?.total ?? 0;
|
||||
const filterAttributes = data?.filterAttributes ?? {
|
||||
providers: [],
|
||||
resources: [],
|
||||
users: [],
|
||||
virtualApiKeys: [],
|
||||
models: []
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
// When the end date has no explicit time, it represents an
|
||||
// open-ended "up to now" upper bound. Since dateRange is only
|
||||
// recomputed on user interaction, that upper bound otherwise stays
|
||||
// frozen at whenever the page first loaded, so refreshing would
|
||||
// never surface logs created since then. Bump it to the current
|
||||
// time so the query key changes and refetches the latest window.
|
||||
if (dateRange.endDate?.date && !dateRange.endDate.time) {
|
||||
setDateRange((prev) => ({
|
||||
...prev,
|
||||
endDate: { date: new Date() }
|
||||
}));
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0);
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
newFilters:
|
||||
| typeof filters
|
||||
| {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
: undefined,
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
};
|
||||
|
||||
const response = await api.get(`/org/${orgId}/logs/ai/export`, {
|
||||
responseType: "blob",
|
||||
params
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`ai-session-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
} catch (error) {
|
||||
let apiErrorMessage: string | null = null;
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: apiErrorMessage ?? t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<span className="px-2">{t("timestamp")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(row.original.createdAt).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "providerName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.providers.map(
|
||||
(provider) => ({
|
||||
value: provider.id.toString(),
|
||||
label: provider.name || "Unnamed Provider"
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.providerId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("providerId", value)
|
||||
}
|
||||
label={t("provider")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Bot className="h-4 w-4" />
|
||||
{row.original.providerName || (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "capability",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={Object.entries(capabilityLabels).map(
|
||||
([value, label]) => ({ value, label })
|
||||
)}
|
||||
selectedValue={filters.capability}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("capability", value)
|
||||
}
|
||||
label={t("capability")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{capabilityLabels[row.original.capability] ||
|
||||
row.original.capability}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "requestedModel",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.models.map((model) => ({
|
||||
value: model,
|
||||
label: model
|
||||
}))}
|
||||
selectedValue={filters.model}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("model", value)
|
||||
}
|
||||
label={t("model")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.requestedModel ? (
|
||||
<span>{row.original.requestedModel}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.resources.map((res) => ({
|
||||
value: res.id.toString(),
|
||||
label: res.name || "Unnamed Resource"
|
||||
}))}
|
||||
selectedValue={filters.resourceId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
label={t("resource")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (
|
||||
!row.original.resourceNiceId ||
|
||||
!row.original.resourceName
|
||||
) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={
|
||||
row.original.resourceType === "site"
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "isStream",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("streaming") },
|
||||
{ value: "false", label: t("nonStreaming") }
|
||||
]}
|
||||
label={t("stream")}
|
||||
selectedValue={filters.isStream}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("isStream", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.isStream ? (
|
||||
<>
|
||||
<Waves className="h-4 w-4" />
|
||||
{t("streaming")}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("nonStreaming")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.users.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.email || user.id
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
label={t("actor")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.userEmail ? (
|
||||
<>
|
||||
<User className="h-4 w-4" />
|
||||
{row.original.userEmail}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "virtualApiKeyId",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.virtualApiKeys.map(
|
||||
(key) => ({
|
||||
value: key.id,
|
||||
label:
|
||||
key.name ??
|
||||
(key.lastChars
|
||||
? formatVirtualApiKeyPreview(
|
||||
key.id,
|
||||
key.lastChars
|
||||
)
|
||||
: key.id)
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.virtualApiKeyId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("virtualApiKeyId", value)
|
||||
}
|
||||
label={t("virtualApiKey")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.virtualApiKeyId) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate">
|
||||
{row.original.virtualApiKeyName ??
|
||||
t("aiUsageUnnamedVirtualApiKey")}
|
||||
</span>
|
||||
{row.original.virtualApiKeyLastChars && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{formatVirtualApiKeyPreview(
|
||||
row.original.virtualApiKeyId,
|
||||
row.original.virtualApiKeyLastChars
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>{t("aiSessionId")}</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.sessionId}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("statusCode")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.statusCode ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("cost")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage && row.usage.costUsd != null
|
||||
? `$${row.usage.costUsd.toFixed(4)}`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("estimated")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage
|
||||
? row.usage.estimated
|
||||
? t("yes")
|
||||
: t("no")
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("totalTokens")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage.totalTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{row.usage && (
|
||||
<div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>{t("promptTokens")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage.promptTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("cacheReadTokens")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage.cacheReadTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("cacheWriteTokens")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage.cacheWriteTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("completionTokens")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage.completionTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("reasoningTokens")}</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.usage.reasoningTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AiSessionChatView
|
||||
normalizedRequest={row.normalizedRequest}
|
||||
normalizedResponse={row.normalizedResponse}
|
||||
requestBody={row.requestBody}
|
||||
responseBody={row.responseBody}
|
||||
truncated={row.truncated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("aiSessionLogs")}
|
||||
description={t("aiSessionLogsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAISessions === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("aiSessionLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("aiSessionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="providerName"
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "createdAt",
|
||||
desc: true
|
||||
}}
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
pageSize={pageSize}
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
||||
const capabilities = Object.keys(capabilityLabels);
|
||||
const providers = [
|
||||
{ id: 1, name: "OpenAI Production" },
|
||||
{ id: 2, name: "Anthropic Default" },
|
||||
{ id: 3, name: "Vertex AI" }
|
||||
];
|
||||
const resourcesSample = [
|
||||
{ id: 1, niceId: "resource-1", name: "Resource 1" },
|
||||
{ id: 2, niceId: "resource-2", name: "Resource 2" }
|
||||
];
|
||||
const actors = ["alice@example.com", "bob@example.com", null];
|
||||
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
|
||||
const virtualApiKeysSample = [
|
||||
{ id: "vak00001", name: "CI pipeline", lastChars: "ab12" },
|
||||
{ id: "vak00002", name: null, lastChars: "cd34" },
|
||||
null
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const provider =
|
||||
providers[Math.floor(Math.random() * providers.length)];
|
||||
const resource =
|
||||
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
|
||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||
const virtualApiKey =
|
||||
virtualApiKeysSample[
|
||||
Math.floor(Math.random() * virtualApiKeysSample.length)
|
||||
];
|
||||
|
||||
return {
|
||||
id: i,
|
||||
sessionId: `sample-session-${i}`,
|
||||
orgId: "sample-org",
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
providerType: "openai",
|
||||
capability:
|
||||
capabilities[Math.floor(Math.random() * capabilities.length)],
|
||||
resourceId: resource.id,
|
||||
siteResourceId: null,
|
||||
resourceName: resource.name,
|
||||
resourceNiceId: resource.niceId,
|
||||
resourceType: "public",
|
||||
userId: actor ? `user-${i}` : null,
|
||||
userEmail: actor,
|
||||
virtualApiKeyId: virtualApiKey?.id ?? null,
|
||||
virtualApiKeyName: virtualApiKey?.name ?? null,
|
||||
virtualApiKeyLastChars: virtualApiKey?.lastChars ?? null,
|
||||
requestedModel: models[Math.floor(Math.random() * models.length)],
|
||||
isStream: Math.random() > 0.5,
|
||||
requestBody: null,
|
||||
responseBody: null,
|
||||
normalizedRequest: null,
|
||||
normalizedResponse: null,
|
||||
truncated: false,
|
||||
statusCode: 200,
|
||||
createdAt: Math.floor(
|
||||
sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs)
|
||||
),
|
||||
usage: {
|
||||
promptTokens: 500,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
completionTokens: 150,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 650,
|
||||
costUsd: 0.0123,
|
||||
estimated: false
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -47,6 +49,7 @@ export default function ConnectionLogsPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
@@ -170,6 +173,23 @@ export default function ConnectionLogsPage() {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
// When the end date has no explicit time, it represents an
|
||||
// open-ended "up to now" upper bound. Since dateRange is only
|
||||
// recomputed on user interaction, that upper bound otherwise stays
|
||||
// frozen at whenever the page first loaded, so refreshing would
|
||||
// never surface logs created since then. Bump it to the current
|
||||
// time so the query key changes and refetches the latest window.
|
||||
if (dateRange.endDate?.date && !dateRange.endDate.time) {
|
||||
setDateRange((prev) => ({
|
||||
...prev,
|
||||
endDate: { date: new Date() }
|
||||
}));
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
@@ -294,7 +314,9 @@ export default function ConnectionLogsPage() {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="whitespace-nowrap font-mono text-xs">
|
||||
{row.original.protocol?.toUpperCase()}
|
||||
{row.original.protocol?.toUpperCase() || (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -321,25 +343,26 @@ export default function ConnectionLogsPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||
if (
|
||||
!row.original.resourceNiceId ||
|
||||
!row.original.resourceName
|
||||
) {
|
||||
return (
|
||||
<Link
|
||||
href={getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{row.original.resourceName ?? "-"}
|
||||
</span>
|
||||
<Link
|
||||
href={getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -379,11 +402,14 @@ export default function ConnectionLogsPage() {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{row.original.clientName ?? "-"}
|
||||
</span>
|
||||
);
|
||||
if (row.original.clientName) {
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{row.original.clientName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground">-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -416,17 +442,19 @@ export default function ConnectionLogsPage() {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>-</span>;
|
||||
return <span className="text-xs text-muted-foreground">-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "sourceAddr",
|
||||
header: () => <span className="px-2">{t("sourceAddress")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
return row.original.sourceAddr ? (
|
||||
<span className="whitespace-nowrap font-mono text-xs">
|
||||
{row.original.sourceAddr}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -452,10 +480,12 @@ export default function ConnectionLogsPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
return row.original.destAddr ? (
|
||||
<span className="whitespace-nowrap font-mono text-xs">
|
||||
{row.original.destAddr}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -555,13 +585,20 @@ export default function ConnectionLogsPage() {
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.connectionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysConnection === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("connectionLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("connectionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="protocol"
|
||||
onRefresh={() => refetch()}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -11,6 +13,8 @@ export default async function GeneralSettingsPage({
|
||||
children,
|
||||
params
|
||||
}: GeneralSettingsProps) {
|
||||
const { orgId } = await params;
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
@@ -18,5 +22,13 @@ export default async function GeneralSettingsPage({
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
return children;
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
return <OrgProvider org={org}>{children}</OrgProvider>;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -31,6 +33,8 @@ export default function GeneralPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
@@ -155,6 +159,23 @@ export default function GeneralPage() {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
// When the end date has no explicit time, it represents an
|
||||
// open-ended "up to now" upper bound. Since dateRange is only
|
||||
// recomputed on user interaction, that upper bound otherwise stays
|
||||
// frozen at whenever the page first loaded, so refreshing would
|
||||
// never surface logs created since then. Bump it to the current
|
||||
// time so the query key changes and refetches the latest window.
|
||||
if (dateRange.endDate?.date && !dateRange.endDate.time) {
|
||||
setDateRange((prev) => ({
|
||||
...prev,
|
||||
endDate: { date: new Date() }
|
||||
}));
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
@@ -259,6 +280,7 @@ export default function GeneralPage() {
|
||||
// 106 - Valid email
|
||||
// 107 - Valid SSO
|
||||
// 108 - Connected Client
|
||||
// 109 - Valid Virtual API Key
|
||||
|
||||
// 201 - Resource Not Found
|
||||
// 202 - Resource Blocked
|
||||
@@ -277,6 +299,7 @@ export default function GeneralPage() {
|
||||
106: t("validEmail"),
|
||||
107: t("validSSO"),
|
||||
108: t("connectedClient"),
|
||||
109: t("validVirtualAPIKey"),
|
||||
201: t("resourceNotFound"),
|
||||
202: t("resourceBlocked"),
|
||||
203: t("droppedByRule"),
|
||||
@@ -357,7 +380,14 @@ export default function GeneralPage() {
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return row.original.ip ? (
|
||||
row.original.ip
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
@@ -422,6 +452,14 @@ export default function GeneralPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (
|
||||
!row.original.resourceNiceId ||
|
||||
!row.original.resourceName
|
||||
) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={
|
||||
@@ -464,6 +502,11 @@ export default function GeneralPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.host) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.tls ? (
|
||||
@@ -496,6 +539,13 @@ export default function GeneralPage() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.path ? (
|
||||
row.original.path
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -530,6 +580,13 @@ export default function GeneralPage() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.method ? (
|
||||
row.original.method
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -572,7 +629,11 @@ export default function GeneralPage() {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{reasonMap[row.original.reason]}
|
||||
{reasonMap[row.original.reason] ?? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -611,7 +672,9 @@ export default function GeneralPage() {
|
||||
{row.original.actor}
|
||||
</>
|
||||
) : (
|
||||
<>-</>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -685,13 +748,20 @@ export default function GeneralPage() {
|
||||
description={t("requestLogsDescription")}
|
||||
/>
|
||||
|
||||
{org.org.settingsLogRetentionDaysRequest === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("requestLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("requestLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="host"
|
||||
onRefresh={() => refetch()}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MachinesSelector } from "@app/components/machines-selector";
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
type AccessFormValues = {
|
||||
roles?: Tag[];
|
||||
users?: Tag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
};
|
||||
|
||||
type PrivateResourceAccessFieldsProps = {
|
||||
control: Control<AccessFormValues>;
|
||||
orgId: string;
|
||||
loading?: boolean;
|
||||
hasMachineClients?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAccessFields({
|
||||
control,
|
||||
orgId,
|
||||
loading = false,
|
||||
hasMachineClients = false
|
||||
}: PrivateResourceAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">{t("loading")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
selectedRoles={field.value ?? []}
|
||||
orgId={orgId}
|
||||
restrictAdminRole
|
||||
onSelectRoles={(newRoles) => {
|
||||
field.onChange(newRoles);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<UsersSelector
|
||||
selectedUsers={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
field.onChange(newUsers);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{hasMachineClients && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("machineClients")}</FormLabel>
|
||||
<MachinesSelector
|
||||
selectedMachines={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(machines) => {
|
||||
field.onChange(machines);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceAliasFieldProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAliasField({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
disabled = false
|
||||
}: PrivateResourceAliasFieldProps) {
|
||||
const t = useTranslations();
|
||||
const aliasLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAlias"
|
||||
: "editInternalResourceDialogAlias";
|
||||
const aliasDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAliasDescription"
|
||||
: "editInternalResourceDialogAliasDescription";
|
||||
|
||||
const aliasValue = watch("alias");
|
||||
const aliasEndsWithLocal =
|
||||
typeof aliasValue === "string" &&
|
||||
aliasValue.trim().toLowerCase().endsWith(".local");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(aliasLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{aliasEndsWithLocal && (
|
||||
<p className="text-xs text-amber-700/80 mt-1">
|
||||
{t("internalResourceAliasLocalWarning")}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PrivateResourceHostDestinationFieldsProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHostDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
hideAlias = false
|
||||
}: PrivateResourceHostDestinationFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
const destinationField = (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (hideAlias) {
|
||||
return destinationField;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceCidrDestinationField({
|
||||
control,
|
||||
labelPrefix = "edit"
|
||||
}: {
|
||||
control: Control<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceHttpFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
orgId: string;
|
||||
watch: UseFormWatch<any>;
|
||||
disabled?: boolean;
|
||||
siteResourceId?: number;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideDomainPicker?: boolean;
|
||||
hidePaidFeaturesAlert?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHttpFields({
|
||||
control,
|
||||
setValue,
|
||||
orgId,
|
||||
watch,
|
||||
disabled = false,
|
||||
siteResourceId,
|
||||
labelPrefix = "edit",
|
||||
hideDomainPicker = false,
|
||||
hidePaidFeaturesAlert = false
|
||||
}: PrivateResourceHttpFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const schemeLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogScheme"
|
||||
: "editInternalResourceDialogScheme";
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
const httpConfigurationTitleKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfiguration"
|
||||
: "editInternalResourceDialogHttpConfiguration";
|
||||
const httpConfigurationDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfigurationDescription"
|
||||
: "editInternalResourceDialogHttpConfigurationDescription";
|
||||
const enableSslLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSsl"
|
||||
: "editInternalResourceDialogEnableSsl";
|
||||
const enableSslDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSslDescription"
|
||||
: "editInternalResourceDialogEnableSslDescription";
|
||||
|
||||
const httpConfigSubdomain = watch("httpConfigSubdomain");
|
||||
const httpConfigDomainId = watch("httpConfigDomainId");
|
||||
const httpConfigFullDomain = watch("httpConfigFullDomain");
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
{!hidePaidFeaturesAlert && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="scheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(schemeLabelKey)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? "http"}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{!hideDomainPicker && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(httpConfigurationTitleKey)}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(httpConfigurationDescriptionKey)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<div
|
||||
className={
|
||||
disabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DomainPicker
|
||||
key={
|
||||
siteResourceId
|
||||
? `http-domain-${siteResourceId}`
|
||||
: "http-domain-create"
|
||||
}
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ?? undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
setValue("httpConfigSubdomain", null);
|
||||
setValue("httpConfigDomainId", null);
|
||||
setValue("httpConfigFullDomain", null);
|
||||
return;
|
||||
}
|
||||
setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(
|
||||
enableSslDescriptionKey
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hideDomainPicker && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(enableSslDescriptionKey)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PrivateResourceMultiSiteRoutingHelp() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
getPortModeFromString,
|
||||
getPortStringFromMode,
|
||||
type PortMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue } from "react-hook-form";
|
||||
|
||||
type PrivateResourceNetworkAccessFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
showPortRanges?: boolean;
|
||||
initialTcp?: string | null;
|
||||
initialUdp?: string | null;
|
||||
disabled?: boolean;
|
||||
icmpId?: string;
|
||||
embedInParentGrid?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAllowIcmpField({
|
||||
control,
|
||||
id = "private-resource-allow-icmp",
|
||||
disabled = false
|
||||
}: {
|
||||
control: Control<any>;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id={id}
|
||||
label={t("privateResourceAllowIcmpPing")}
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(!checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateResourceNetworkAccessHeader() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("privateResourceNetworkAccess")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("privateResourceNetworkAccessDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceNetworkAccessFields({
|
||||
control,
|
||||
setValue,
|
||||
showPortRanges = true,
|
||||
initialTcp,
|
||||
initialUdp,
|
||||
disabled = false,
|
||||
icmpId = "private-resource-allow-icmp",
|
||||
embedInParentGrid = false
|
||||
}: PrivateResourceNetworkAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const resolvedInitialTcp = initialTcp !== undefined ? initialTcp : "*";
|
||||
const resolvedInitialUdp = initialUdp !== undefined ? initialUdp : "*";
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(resolvedInitialTcp)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(resolvedInitialUdp)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
|
||||
resolvedInitialTcp && resolvedInitialTcp !== "*"
|
||||
? resolvedInitialTcp
|
||||
: ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
|
||||
resolvedInitialUdp && resolvedInitialUdp !== "*"
|
||||
? resolvedInitialUdp
|
||||
: ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"tcpPortRangeString",
|
||||
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"udpPortRangeString",
|
||||
getPortStringFromMode(udpPortMode, udpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
<PrivateResourceNetworkAccessHeader />
|
||||
|
||||
{showPortRanges ? (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="tcpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogTcp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setTcpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="udpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogUdp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setUdpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceAllowIcmpField
|
||||
control={control}
|
||||
id={icmpId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
|
||||
export function PrivateResourcePortRanges(
|
||||
props: Omit<
|
||||
PrivateResourceNetworkAccessFieldsProps,
|
||||
"showPortRanges" | "embedInParentGrid"
|
||||
>
|
||||
) {
|
||||
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "@app/components/multi-site-selector";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
||||
|
||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
siteIdsFieldName?: FieldPath<T>;
|
||||
singleSite?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSitesField<T extends FieldValues>({
|
||||
control,
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
siteIdsFieldName = "siteIds" as FieldPath<T>,
|
||||
singleSite = false
|
||||
}: PrivateResourceSitesFieldProps<T>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={siteIdsFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
{singleSite ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedSites[0]?.name ??
|
||||
t("selectSite")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSites[0] ?? null}
|
||||
filterTypes={["newt"]}
|
||||
onSelectSite={(site) => {
|
||||
onSelectedSitesChange([site]);
|
||||
field.onChange([site.siteId]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
filterTypes={["newt"]}
|
||||
onSelectionChange={(sites) => {
|
||||
onSelectedSitesChange(sites);
|
||||
field.onChange(
|
||||
sites.map((s) => s.siteId)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<FormMessage />
|
||||
{!singleSite && selectedSites.length > 1 ? (
|
||||
<PrivateResourceMultiSiteRoutingHelp />
|
||||
) : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
||||
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type PrivateResourceSshFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
orgId?: string;
|
||||
disabled?: boolean;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
labelPrefix?: "create" | "edit";
|
||||
showSshSettings?: boolean;
|
||||
layout?: "default" | "wizard";
|
||||
showPaidFeaturesAlert?: boolean;
|
||||
hideAlias?: boolean;
|
||||
embedInParentGrid?: boolean;
|
||||
isNativeSsh?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSshFields({
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
orgId,
|
||||
disabled = false,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
labelPrefix = "edit",
|
||||
showSshSettings = true,
|
||||
layout = "default",
|
||||
showPaidFeaturesAlert = true,
|
||||
hideAlias = false,
|
||||
embedInParentGrid = false,
|
||||
isNativeSsh: isNativeSshProp
|
||||
}: PrivateResourceSshFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
|
||||
const authDaemonMode = watch("authDaemonMode") ?? "site";
|
||||
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
|
||||
const standardDaemonLocation =
|
||||
watch("standardDaemonLocation") ??
|
||||
(authDaemonMode === "remote" ? "remote" : "site");
|
||||
const formAuthDaemonPort = watch("authDaemonPort");
|
||||
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
|
||||
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
|
||||
);
|
||||
const isEditLayout = layout === "default";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
() => (authDaemonMode === "native" ? "native" : "standard")
|
||||
);
|
||||
|
||||
const isNative =
|
||||
isNativeSshProp ??
|
||||
(isEditLayout
|
||||
? authDaemonMode === "native"
|
||||
: sshServerMode === "native");
|
||||
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
|
||||
isNative,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
);
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
onSelectedSitesChange(first);
|
||||
setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "passthrough") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
return;
|
||||
}
|
||||
|
||||
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("standardDaemonLocation", value, { shouldValidate: true });
|
||||
setValue("authDaemonMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "site") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDaemonPortChange(value: string) {
|
||||
if (disabled) return;
|
||||
|
||||
setAuthDaemonPortInput(value);
|
||||
const trimmed = value.trim();
|
||||
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
|
||||
function handleServerModeChange(mode: "standard" | "native") {
|
||||
if (disabled) return;
|
||||
|
||||
setSshServerMode(mode);
|
||||
if (mode === "native") {
|
||||
setValue("authDaemonMode", "native", { shouldValidate: true });
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setValue("destination", null, { shouldValidate: true });
|
||||
setValue("destinationPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("authDaemonMode", standardDaemonLocation, {
|
||||
shouldValidate: true
|
||||
});
|
||||
setValue("destinationPort", 22, { shouldValidate: true });
|
||||
}
|
||||
|
||||
const aliasField = hideAlias ? null : (
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const standardSshTargetRow =
|
||||
orgId && !isNative ? (
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite={!useMultiSiteTargetForm}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const sshSettingsFields = showSshSettings ? (
|
||||
<SshServerSettingsFields
|
||||
idPrefix={
|
||||
layout === "wizard"
|
||||
? "private-ssh-create"
|
||||
: "private-ssh-fields"
|
||||
}
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={standardDaemonLocation}
|
||||
authDaemonPort={authDaemonPortInput}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={handleDaemonLocationChange}
|
||||
onAuthDaemonPortChange={handleAuthDaemonPortChange}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
|
||||
onServerModeChange={handleServerModeChange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const destinationSection = (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
|
||||
{isNative && orgId ? (
|
||||
<>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isNative && orgId ? (
|
||||
<SettingsFormCell span="full">
|
||||
{standardSshTargetRow}
|
||||
</SettingsFormCell>
|
||||
) : null}
|
||||
|
||||
{!isNative && !hideAlias ? (
|
||||
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
{showPaidFeaturesAlert && layout === "default" && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{sshSettingsFields}
|
||||
{destinationSection}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
||||
import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
|
||||
|
||||
export default function PrivateResourceAccessPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
AiProviderAttachments,
|
||||
type AiProviderAttachmentValue
|
||||
} from "@app/components/AiProviderAttachments";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function PrivateResourceInferencePage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const { siteResource } = useSiteResourceContext();
|
||||
const { save } = useSaveSiteResource();
|
||||
|
||||
useEffect(() => {
|
||||
if (siteResource.mode !== "inference") {
|
||||
router.replace(
|
||||
`/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general`
|
||||
);
|
||||
}
|
||||
}, [router, siteResource.mode, siteResource.niceId, siteResource.orgId]);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
providerId: z.number().int().positive(),
|
||||
niceId: z.string(),
|
||||
name: z.string(),
|
||||
accessMode: z.enum(["inherit", "select"]),
|
||||
enabled: z.boolean(),
|
||||
selectedModelIds: z.array(z.number().int().positive())
|
||||
})
|
||||
),
|
||||
httpConfigSubdomain: z.string().nullish(),
|
||||
httpConfigDomainId: z.string().nullish(),
|
||||
httpConfigFullDomain: z.string().nullish(),
|
||||
ssl: z.boolean().optional()
|
||||
}),
|
||||
[]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const attachedQuery = useQuery({
|
||||
...resourceQueries.siteResourceAiProviders({
|
||||
siteResourceId: siteResource.id
|
||||
}),
|
||||
enabled: siteResource.mode === "inference"
|
||||
});
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
...resourceQueries.siteResourceAiModels({
|
||||
siteResourceId: siteResource.id
|
||||
}),
|
||||
enabled: siteResource.mode === "inference"
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providers: [],
|
||||
httpConfigSubdomain: siteResource.subdomain ?? null,
|
||||
httpConfigDomainId: siteResource.domainId ?? null,
|
||||
httpConfigFullDomain: siteResource.fullDomain ?? null,
|
||||
ssl: siteResource.ssl ?? false
|
||||
}
|
||||
});
|
||||
|
||||
const httpConfigSubdomain = form.watch("httpConfigSubdomain");
|
||||
const httpConfigDomainId = form.watch("httpConfigDomainId");
|
||||
const httpConfigFullDomain = form.watch("httpConfigFullDomain");
|
||||
|
||||
useEffect(() => {
|
||||
if (!attachedQuery.data) return;
|
||||
const hasSelect = attachedQuery.data.some(
|
||||
(provider) => provider.accessMode === "select"
|
||||
);
|
||||
if (hasSelect && modelsQuery.isLoading) return;
|
||||
|
||||
const modelsByProvider = new Map<number, number[]>();
|
||||
for (const model of modelsQuery.data ?? []) {
|
||||
if (model.listType !== "allow") continue;
|
||||
const existing = modelsByProvider.get(model.providerId) ?? [];
|
||||
existing.push(model.modelId);
|
||||
modelsByProvider.set(model.providerId, existing);
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
"providers",
|
||||
attachedQuery.data.map((provider) => ({
|
||||
providerId: provider.providerId,
|
||||
niceId: provider.niceId,
|
||||
name: provider.name,
|
||||
accessMode: provider.accessMode,
|
||||
enabled: provider.enabled,
|
||||
selectedModelIds:
|
||||
provider.accessMode === "select"
|
||||
? (modelsByProvider.get(provider.providerId) ?? [])
|
||||
: []
|
||||
}))
|
||||
);
|
||||
}, [attachedQuery.data, modelsQuery.data, modelsQuery.isLoading, form]);
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
try {
|
||||
await save({
|
||||
mode: "inference",
|
||||
httpConfigSubdomain: data.httpConfigSubdomain,
|
||||
httpConfigDomainId: data.httpConfigDomainId,
|
||||
httpConfigFullDomain: data.httpConfigFullDomain,
|
||||
ssl: data.ssl
|
||||
});
|
||||
|
||||
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
|
||||
providers: data.providers.map((provider) => ({
|
||||
providerId: provider.providerId,
|
||||
accessMode: provider.accessMode,
|
||||
enabled: provider.enabled
|
||||
}))
|
||||
});
|
||||
|
||||
const selectProviders = data.providers.filter(
|
||||
(provider) => provider.accessMode === "select"
|
||||
);
|
||||
if (selectProviders.length > 0) {
|
||||
await api.post(`/site-resource/${siteResource.id}/ai-models`, {
|
||||
models: selectProviders.flatMap((provider) =>
|
||||
provider.selectedModelIds.map((modelId) => ({
|
||||
modelId,
|
||||
listType: "allow" as const
|
||||
}))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceAiProviders({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceAiModels({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiResourceProvidersUpdated")
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiResourceProvidersErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("aiResourceProvidersErrorUpdate")
|
||||
)
|
||||
});
|
||||
}
|
||||
}, null);
|
||||
|
||||
if (siteResource.mode !== "inference") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providersLoading =
|
||||
attachedQuery.isLoading ||
|
||||
(attachedQuery.data?.some((p) => p.accessMode === "select") &&
|
||||
modelsQuery.isLoading);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiResourceProviders")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiResourceProvidersDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-providers-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiResourceProviders"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProviderAttachments
|
||||
orgId={
|
||||
siteResource.orgId
|
||||
}
|
||||
value={
|
||||
field.value as AiProviderAttachmentValue[]
|
||||
}
|
||||
disabled={
|
||||
providersLoading
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(
|
||||
"aiResourceDomainConfiguration"
|
||||
)}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(
|
||||
"aiResourceDomainConfigurationDescription"
|
||||
)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<DomainPicker
|
||||
key={`inference-domain-${siteResource.id}`}
|
||||
orgId={siteResource.orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ??
|
||||
undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-inference-ssl"
|
||||
label={t(
|
||||
"editInternalResourceDialogEnableSsl"
|
||||
)}
|
||||
description={t(
|
||||
"editInternalResourceDialogEnableSslDescription"
|
||||
)}
|
||||
checked={
|
||||
!!field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-providers-form"
|
||||
loading={saveLoading}
|
||||
disabled={providersLoading || saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
import { BudgetsEditor } from "@app/components/BudgetsEditor";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function PrivateResourceBudgetPage() {
|
||||
const { siteResource } = useSiteResourceContext();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
if (siteResource.mode !== "inference") {
|
||||
router.replace(
|
||||
`/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general`
|
||||
);
|
||||
}
|
||||
}, [
|
||||
router,
|
||||
siteResource.mode,
|
||||
siteResource.niceId,
|
||||
siteResource.orgId
|
||||
]);
|
||||
|
||||
if (siteResource.mode !== "inference") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<BudgetsEditor
|
||||
orgId={siteResource.orgId}
|
||||
scope={{ type: "siteResource", id: siteResource.id }}
|
||||
title={t("resourceBudgetSettings")}
|
||||
description={t("resourceBudgetSettingsDescription")}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceCidrPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -16,19 +16,23 @@ import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useActionState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
@@ -41,7 +45,8 @@ export default function PrivateResourceGeneralPage() {
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: siteResource.name,
|
||||
niceId: siteResource.niceId
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,7 +57,8 @@ export default function PrivateResourceGeneralPage() {
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
name: data.name,
|
||||
niceId: data.niceId
|
||||
niceId: data.niceId,
|
||||
enabled: data.enabled
|
||||
});
|
||||
}, null);
|
||||
|
||||
@@ -65,6 +71,25 @@ export default function PrivateResourceGeneralPage() {
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("privateResourceGeneralDescription")}
|
||||
{siteResource.mode === "inference" ? (
|
||||
<>
|
||||
{" "}
|
||||
{t.rich(
|
||||
"resourceGeneralAiClientConfigDescription",
|
||||
{
|
||||
configLink: (chunks) => (
|
||||
<Link
|
||||
href={`/${siteResource.orgId}?openResource=${encodeURIComponent(siteResource.niceId)}&openResourceQuery=${encodeURIComponent(siteResource.name)}`}
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
@@ -76,6 +101,42 @@ export default function PrivateResourceGeneralPage() {
|
||||
id="private-resource-general-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={
|
||||
siteResource.enabled
|
||||
}
|
||||
label={t(
|
||||
"resourceEnable"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) =>
|
||||
form.setValue(
|
||||
"enabled",
|
||||
val
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"disabledResourceDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceHostPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -22,23 +22,19 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceHttpPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
@@ -120,7 +116,7 @@ export default function PrivateResourceHttpPage() {
|
||||
)}
|
||||
orgId={siteResource.orgId}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
disabled={httpSectionDisabled}
|
||||
disabled={false}
|
||||
siteResourceId={siteResource.id}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
@@ -135,7 +131,6 @@ export default function PrivateResourceHttpPage() {
|
||||
type="submit"
|
||||
form="private-resource-http-form"
|
||||
loading={saveLoading}
|
||||
disabled={httpSectionDisabled}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import SiteResourceProvider from "@app/providers/SiteResourceProvider";
|
||||
import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox";
|
||||
import SiteResourceInfoBox from "@app/components/PrivateResourceInfoBox";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -52,7 +52,8 @@ export default async function PrivateResourceLayout(
|
||||
| "hostSettings"
|
||||
| "cidrSettings"
|
||||
| "httpSettings"
|
||||
| "sshSettings";
|
||||
| "sshSettings"
|
||||
| "inferenceSettings";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
@@ -61,7 +62,7 @@ export default async function PrivateResourceLayout(
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode === "inference" ? "ai-gateway" : siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
@@ -69,6 +70,13 @@ export default async function PrivateResourceLayout(
|
||||
}
|
||||
];
|
||||
|
||||
if (siteResource.mode === "inference") {
|
||||
navItems.push({
|
||||
title: t("resourceBudgetSettings"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/budget`
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
|
||||
@@ -12,35 +12,30 @@ import {
|
||||
SettingsFormGrid
|
||||
} from "@app/components/Settings";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import {
|
||||
createSshFormSchema,
|
||||
inferSshPamMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceSshPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const isNative = siteResource.authDaemonMode === "native";
|
||||
const [sshServerMode] = useState<"standard" | "native">(
|
||||
isNative ? "native" : "standard"
|
||||
@@ -150,7 +145,6 @@ export default function PrivateResourceSshPage() {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
@@ -161,68 +155,56 @@ export default function PrivateResourceSshPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SshServerSettingsFields
|
||||
idPrefix="private-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={
|
||||
handleDaemonLocationChange
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue(
|
||||
"authDaemonPort",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(form.setValue)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={setSelectedSites}
|
||||
showSshSettings={false}
|
||||
embedInParentGrid
|
||||
showPaidFeaturesAlert={false}
|
||||
isNativeSsh={isNative}
|
||||
/>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SshServerSettingsFields
|
||||
idPrefix="private-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={
|
||||
handleDaemonLocationChange
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue("authDaemonPort", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(form.setValue)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={setSelectedSites}
|
||||
showSshSettings={false}
|
||||
embedInParentGrid
|
||||
isNativeSsh={isNative}
|
||||
/>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<form action={formAction}>
|
||||
<Button type="submit" loading={saveLoading}>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSectionFooter>
|
||||
</Form>
|
||||
</fieldset>
|
||||
<SettingsSectionFooter>
|
||||
<form action={formAction}>
|
||||
<Button type="submit" loading={saveLoading}>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSectionFooter>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
|
||||
@@ -12,11 +12,10 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
DescribedSelect,
|
||||
type DescribedSelectOption
|
||||
} from "@app/components/DescribedSelect";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
@@ -50,16 +48,24 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import {
|
||||
PrivateResourceAliasField,
|
||||
PrivateResourceCidrDestinationField,
|
||||
PrivateResourceHostDestinationFields
|
||||
} from "../PrivateResourceDestinationFields";
|
||||
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
||||
} from "@app/components/PrivateResourceDestinationFields";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "@app/lib/formControlUtils";
|
||||
import {
|
||||
AiProvidersSelector,
|
||||
type SelectedAiProvider
|
||||
} from "@app/components/AiProvidersSelector";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
@@ -69,12 +75,6 @@ export default function CreatePrivateResourcePage() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const orgId = params.orgId as string;
|
||||
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
const siteIdParam = searchParams.get("siteId");
|
||||
@@ -84,6 +84,9 @@ export default function CreatePrivateResourcePage() {
|
||||
: null;
|
||||
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [selectedProviders, setSelectedProviders] = useState<
|
||||
SelectedAiProvider[]
|
||||
>([]);
|
||||
|
||||
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -108,7 +111,8 @@ export default function CreatePrivateResourcePage() {
|
||||
pamMode: "passthrough",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false
|
||||
disableIcmp: false,
|
||||
providerIds: []
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,28 +139,34 @@ export default function CreatePrivateResourcePage() {
|
||||
const authDaemonMode = form.watch("authDaemonMode");
|
||||
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
|
||||
|
||||
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
|
||||
{ value: "host", label: t("createInternalResourceDialogModeHost") },
|
||||
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
|
||||
...(!disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
value: "http" as const,
|
||||
label: t("createInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "ssh" as const,
|
||||
label: t("createInternalResourceDialogModeSsh")
|
||||
}
|
||||
]
|
||||
: [])
|
||||
const modeOptions: DescribedSelectOption<PrivateResourceMode>[] = [
|
||||
{
|
||||
value: "host",
|
||||
title: t("createInternalResourceDialogModeHost"),
|
||||
description: t("privateResourceTypeHostDescription")
|
||||
},
|
||||
{
|
||||
value: "cidr",
|
||||
title: t("createInternalResourceDialogModeCidr"),
|
||||
description: t("privateResourceTypeCidrDescription")
|
||||
},
|
||||
{
|
||||
value: "http" as const,
|
||||
title: t("createInternalResourceDialogModeHttp"),
|
||||
description: t("privateResourceTypeHttpDescription")
|
||||
},
|
||||
{
|
||||
value: "ssh" as const,
|
||||
title: t("createInternalResourceDialogModeSsh"),
|
||||
description: t("privateResourceTypeSshDescription")
|
||||
},
|
||||
{
|
||||
value: "inference" as const,
|
||||
title: t("createInternalResourceDialogModeInference"),
|
||||
description: t("resourceTypeInferenceDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const submitDisabled =
|
||||
isSubmitting ||
|
||||
(mode === "http" && httpSectionDisabled) ||
|
||||
(mode === "ssh" && sshSectionDisabled);
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
@@ -188,7 +198,9 @@ export default function CreatePrivateResourcePage() {
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||
created.mode === "inference"
|
||||
? `/${orgId}/settings/resources/private/${created.niceId}/general`
|
||||
: `/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -242,6 +254,110 @@ export default function CreatePrivateResourcePage() {
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("type")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DescribedSelect<PrivateResourceMode>
|
||||
options={
|
||||
modeOptions
|
||||
}
|
||||
value={field.value}
|
||||
onChange={(
|
||||
newMode
|
||||
) => {
|
||||
field.onChange(
|
||||
newMode
|
||||
);
|
||||
if (
|
||||
newMode ===
|
||||
"ssh"
|
||||
) {
|
||||
form.setValue(
|
||||
"authDaemonMode",
|
||||
"native"
|
||||
);
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
"site"
|
||||
);
|
||||
form.setValue(
|
||||
"destination",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
} else if (
|
||||
newMode ===
|
||||
"http"
|
||||
) {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
443
|
||||
);
|
||||
} else if (
|
||||
newMode ===
|
||||
"inference"
|
||||
) {
|
||||
form.setValue(
|
||||
"siteIds",
|
||||
[]
|
||||
);
|
||||
setSelectedSites(
|
||||
[]
|
||||
);
|
||||
form.setValue(
|
||||
"destination",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"providerIds",
|
||||
[]
|
||||
);
|
||||
setSelectedProviders(
|
||||
[]
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
}
|
||||
}}
|
||||
searchPlaceholder={t(
|
||||
"resourceTypeSearch"
|
||||
)}
|
||||
emptyMessage={t(
|
||||
"resourceTypeNotFound"
|
||||
)}
|
||||
placeholder={t(
|
||||
"noneSelected"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"privateResourceTypeDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -265,110 +381,63 @@ export default function CreatePrivateResourcePage() {
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("type")}
|
||||
</FormLabel>
|
||||
<OptionSelect<PrivateResourceMode>
|
||||
options={modeOptions}
|
||||
value={field.value}
|
||||
onChange={(newMode) => {
|
||||
field.onChange(
|
||||
newMode
|
||||
);
|
||||
if (
|
||||
newMode ===
|
||||
"ssh"
|
||||
) {
|
||||
form.setValue(
|
||||
"authDaemonMode",
|
||||
"native"
|
||||
);
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
"site"
|
||||
);
|
||||
form.setValue(
|
||||
"destination",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
} else if (
|
||||
newMode ===
|
||||
"http"
|
||||
) {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
443
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
}
|
||||
}}
|
||||
cols={4}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{mode === "http" && (
|
||||
{(mode === "http" ||
|
||||
mode === "inference") && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
onDomainChange={(res) => {
|
||||
if (!res) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ??
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceDomainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="httpConfigDomainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
if (!res) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ??
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceDomainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
@@ -381,10 +450,6 @@ export default function CreatePrivateResourcePage() {
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
disabled={
|
||||
mode === "ssh" &&
|
||||
sshSectionDisabled
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
@@ -498,9 +563,6 @@ export default function CreatePrivateResourcePage() {
|
||||
{/* HTTP configuration */}
|
||||
{mode === "http" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
@@ -511,101 +573,132 @@ export default function CreatePrivateResourcePage() {
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={httpSectionDisabled}
|
||||
className={
|
||||
httpSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={
|
||||
selectedSites
|
||||
}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={orgId}
|
||||
watch={asAnyWatch(
|
||||
form.watch
|
||||
)}
|
||||
disabled={
|
||||
httpSectionDisabled
|
||||
}
|
||||
labelPrefix="create"
|
||||
hideDomainPicker
|
||||
hidePaidFeaturesAlert
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={orgId}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
hideDomainPicker
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* SSH server */}
|
||||
{mode === "ssh" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshServer")}
|
||||
{t("sshSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={orgId}
|
||||
disabled={sshSectionDisabled}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
labelPrefix="create"
|
||||
showSshSettings={true}
|
||||
layout="wizard"
|
||||
showPaidFeaturesAlert={false}
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(form.setValue)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={setSelectedSites}
|
||||
labelPrefix="create"
|
||||
showSshSettings={true}
|
||||
layout="wizard"
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{mode === "inference" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiResourceProviders")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiResourceProvidersDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiResourceProviders"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProvidersSelector
|
||||
orgId={orgId}
|
||||
selectedProviders={
|
||||
selectedProviders
|
||||
}
|
||||
onSelectProviders={(
|
||||
providers
|
||||
) => {
|
||||
setSelectedProviders(
|
||||
providers
|
||||
);
|
||||
form.setValue(
|
||||
"providerIds",
|
||||
providers.map(
|
||||
(
|
||||
p
|
||||
) =>
|
||||
parseInt(
|
||||
p.id,
|
||||
10
|
||||
)
|
||||
),
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
@@ -625,7 +718,7 @@ export default function CreatePrivateResourcePage() {
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-private-resource-form"
|
||||
disabled={submitDisabled}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type {
|
||||
Control,
|
||||
FieldValues,
|
||||
UseFormSetValue,
|
||||
UseFormWatch
|
||||
} from "react-hook-form";
|
||||
|
||||
export function asAnyControl<T extends FieldValues>(
|
||||
control: Control<T>
|
||||
): Control<any> {
|
||||
return control as Control<any>;
|
||||
}
|
||||
|
||||
export function asAnySetValue<T extends FieldValues>(
|
||||
setValue: UseFormSetValue<T>
|
||||
): UseFormSetValue<any> {
|
||||
return setValue as UseFormSetValue<any>;
|
||||
}
|
||||
|
||||
export function asAnyWatch<T extends FieldValues>(
|
||||
watch: UseFormWatch<T>
|
||||
): UseFormWatch<any> {
|
||||
return watch as UseFormWatch<any>;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
||||
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
||||
import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable";
|
||||
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { build } from "@server/build";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
@@ -27,6 +29,7 @@ export default async function ClientResourcesPage(
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
||||
@@ -58,7 +61,7 @@ export default async function ClientResourcesPage(
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||
const internalResourceRows: PrivateResourceRow[] = siteResources.map(
|
||||
(siteResource) => {
|
||||
return {
|
||||
id: siteResource.siteResourceId,
|
||||
@@ -98,6 +101,42 @@ export default async function ClientResourcesPage(
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Prefetched in one batched call so the table doesn't fire a separate
|
||||
// certificate request per visible row once it mounts on the client.
|
||||
const certDomains = Array.from(
|
||||
new Set(
|
||||
internalResourceRows
|
||||
.filter(
|
||||
(r) =>
|
||||
r.mode === "http" &&
|
||||
!r.alias &&
|
||||
r.ssl &&
|
||||
r.domainId &&
|
||||
r.fullDomain
|
||||
)
|
||||
.map((r) => r.fullDomain as string)
|
||||
)
|
||||
);
|
||||
|
||||
let initialCertificates: GetBatchedCertificateResponse | undefined;
|
||||
if (build !== "oss" && certDomains.length > 0) {
|
||||
try {
|
||||
const certSearchParams = new URLSearchParams(
|
||||
certDomains.map((domain) => ["domains", domain])
|
||||
);
|
||||
const certRes = await internal.get<
|
||||
AxiosResponse<GetBatchedCertificateResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
initialCertificates = certRes.data.data;
|
||||
} catch {
|
||||
// leave undefined so each row falls back to fetching its own
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
@@ -116,6 +155,7 @@ export default async function ClientResourcesPage(
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
initialCertificates={initialCertificates}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||
|
||||
export function buildSelectedSitesForResource(
|
||||
resource: Pick<SiteResourceData, "siteIds" | "siteNames">
|
||||
): Selectedsite[] {
|
||||
return resource.siteIds.map((siteId, idx) => ({
|
||||
name: resource.siteNames[idx] ?? "",
|
||||
siteId,
|
||||
type: "newt" as const
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSshSingleSiteMode(
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
return (
|
||||
authDaemonMode === "native" ||
|
||||
(pamMode === "push" && authDaemonMode === "site")
|
||||
);
|
||||
}
|
||||
|
||||
export function getSshUseMultiSiteTargetForm(
|
||||
isNative: boolean,
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
if (isNative) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authDaemonMode !== "site" || pamMode === "passthrough";
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import {
|
||||
buildUpdateSiteResourcePayload,
|
||||
mergeFormValuesWithResource,
|
||||
type PrivateResourceFormValues
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function useSaveSiteResource() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { siteResource, updateSiteResource, access } =
|
||||
useSiteResourceContext();
|
||||
|
||||
async function save(
|
||||
partial: Partial<PrivateResourceFormValues>,
|
||||
options?: { successMessage?: string }
|
||||
) {
|
||||
const merged = mergeFormValuesWithResource(siteResource, partial);
|
||||
const isNativeSsh =
|
||||
merged.mode === "ssh" && merged.authDaemonMode === "native";
|
||||
const trimmedDestination = merged.destination?.trim();
|
||||
|
||||
const payload = buildUpdateSiteResourcePayload(
|
||||
{
|
||||
...merged,
|
||||
destination: isNativeSsh
|
||||
? null
|
||||
: trimmedDestination && trimmedDestination.length > 0
|
||||
? trimmedDestination
|
||||
: null
|
||||
},
|
||||
access
|
||||
);
|
||||
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||
|
||||
updateSiteResource({
|
||||
name: merged.name,
|
||||
niceId: merged.niceId ?? siteResource.niceId,
|
||||
enabled: merged.enabled ?? siteResource.enabled,
|
||||
siteIds: merged.siteIds,
|
||||
mode: merged.mode,
|
||||
destination: merged.destination ?? null,
|
||||
alias: merged.alias ?? null,
|
||||
destinationPort: merged.destinationPort ?? null,
|
||||
scheme: merged.scheme ?? siteResource.scheme,
|
||||
ssl: merged.ssl ?? siteResource.ssl,
|
||||
subdomain: merged.httpConfigSubdomain ?? null,
|
||||
domainId: merged.httpConfigDomainId ?? null,
|
||||
fullDomain: merged.httpConfigFullDomain ?? null,
|
||||
tcpPortRangeString: merged.tcpPortRangeString ?? null,
|
||||
udpPortRangeString: merged.udpPortRangeString ?? null,
|
||||
disableIcmp: merged.disableIcmp ?? false,
|
||||
authDaemonMode: merged.authDaemonMode ?? null,
|
||||
authDaemonPort: merged.authDaemonPort ?? null,
|
||||
pamMode: merged.pamMode ?? null
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description:
|
||||
options?.successMessage ??
|
||||
t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
)
|
||||
});
|
||||
|
||||
if (merged.niceId && merged.niceId !== siteResource.niceId) {
|
||||
router.replace(
|
||||
getPrivateResourceSettingsHref(
|
||||
siteResource.orgId,
|
||||
merged.niceId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return { save, siteResource, access };
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { type GetResourceResponse } from "@server/routers/resource";
|
||||
import { CreateTargetResponse } from "@server/routers/target";
|
||||
@@ -63,9 +63,11 @@ import { ExternalLink, Info, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
forwardRef,
|
||||
useActionState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
@@ -80,27 +82,65 @@ export type LocalTarget = Omit<
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
interface ProxyResourceTargetsFormProps {
|
||||
export type ProxyResourceTargetsFormHandle = {
|
||||
save: (options?: { silent?: boolean }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED_METHODS: ("http" | "https" | "h2c")[] = [
|
||||
"http",
|
||||
"https",
|
||||
"h2c"
|
||||
];
|
||||
const EMPTY_TARGETS: LocalTarget[] = [];
|
||||
|
||||
type ProxyResourceTargetsFormProps = {
|
||||
orgId: string;
|
||||
isHttp: boolean;
|
||||
initialTargets?: LocalTarget[];
|
||||
/** Edit mode: when provided, shows a save button and polls for health status */
|
||||
/** Edit mode for a public resource: save button + health polling */
|
||||
resource?: GetResourceResponse;
|
||||
/** Edit mode for an AI provider: save button + health polling */
|
||||
providerId?: number;
|
||||
updateResource?: ResourceContextType["updateResource"];
|
||||
/** Create mode: called whenever the targets list changes */
|
||||
onChange?: (targets: LocalTarget[]) => void;
|
||||
}
|
||||
/** HTTP method options for address selector. Defaults to http/https/h2c. */
|
||||
allowedMethods?: ("http" | "https" | "h2c")[];
|
||||
emptyMessage?: string;
|
||||
/** Render table without its own SettingsSection wrapper */
|
||||
embedded?: boolean;
|
||||
/** Hide the built-in save button (use ref.save from parent) */
|
||||
hideSaveButton?: boolean;
|
||||
/** Hide the advanced mode toggle and always use non-advanced mode (e.g. AI providers) */
|
||||
disableAdvancedMode?: boolean;
|
||||
/** Targets picker is for an AI provider (changes which routing warnings are shown) */
|
||||
isAiProvider?: boolean;
|
||||
};
|
||||
|
||||
export function ProxyResourceTargetsForm({
|
||||
orgId,
|
||||
isHttp,
|
||||
initialTargets = [],
|
||||
resource,
|
||||
updateResource,
|
||||
onChange
|
||||
}: ProxyResourceTargetsFormProps) {
|
||||
export const ProxyResourceTargetsForm = forwardRef<
|
||||
ProxyResourceTargetsFormHandle,
|
||||
ProxyResourceTargetsFormProps
|
||||
>(function ProxyResourceTargetsForm(
|
||||
{
|
||||
orgId,
|
||||
isHttp,
|
||||
initialTargets = EMPTY_TARGETS,
|
||||
resource,
|
||||
providerId,
|
||||
updateResource,
|
||||
onChange,
|
||||
allowedMethods = DEFAULT_ALLOWED_METHODS,
|
||||
emptyMessage,
|
||||
embedded = false,
|
||||
hideSaveButton = false,
|
||||
disableAdvancedMode = false,
|
||||
isAiProvider = false
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const isEditMode = !!resource || !!providerId;
|
||||
|
||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||
@@ -111,7 +151,7 @@ export function ProxyResourceTargetsForm({
|
||||
}, [targets]);
|
||||
|
||||
// Poll health status only in edit mode
|
||||
const { data: polledTargets } = useQuery({
|
||||
const { data: polledResourceTargets } = useQuery({
|
||||
...resourceQueries.resourceTargets({
|
||||
resourceId: resource?.resourceId ?? 0
|
||||
}),
|
||||
@@ -119,6 +159,18 @@ export function ProxyResourceTargetsForm({
|
||||
enabled: !!resource
|
||||
});
|
||||
|
||||
const { data: polledProviderTargets } = useQuery({
|
||||
...aiProviderQueries.providerTargets({
|
||||
providerId: providerId ?? 0
|
||||
}),
|
||||
refetchInterval: 10_000,
|
||||
enabled: !!providerId
|
||||
});
|
||||
|
||||
const polledTargets = providerId
|
||||
? polledProviderTargets
|
||||
: polledResourceTargets;
|
||||
|
||||
useEffect(() => {
|
||||
if (!polledTargets) return;
|
||||
setTargets((prev) =>
|
||||
@@ -182,6 +234,9 @@ export function ProxyResourceTargetsForm({
|
||||
);
|
||||
|
||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||
if (disableAdvancedMode) {
|
||||
return false;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("proxy-advanced-mode");
|
||||
return saved === "true";
|
||||
@@ -207,6 +262,14 @@ export function ProxyResourceTargetsForm({
|
||||
})
|
||||
);
|
||||
|
||||
const { data: remoteExitNodes = [] } = useQuery({
|
||||
...orgQueries.remoteExitNodes({ orgId }),
|
||||
enabled: build === "saas" && isAiProvider
|
||||
});
|
||||
const hasRemoteExitNodes = remoteExitNodes.some(
|
||||
(node) => node.exitNodeId !== null
|
||||
);
|
||||
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
@@ -221,7 +284,7 @@ export function ProxyResourceTargetsForm({
|
||||
);
|
||||
});
|
||||
},
|
||||
[sites]
|
||||
[]
|
||||
);
|
||||
|
||||
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
|
||||
@@ -427,6 +490,7 @@ export function ProxyResourceTargetsForm({
|
||||
isHttp={isHttp}
|
||||
proxyTarget={row.original}
|
||||
updateTarget={updateTarget}
|
||||
allowedMethods={allowedMethods}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -570,22 +634,27 @@ export function ProxyResourceTargetsForm({
|
||||
}, [
|
||||
isAdvancedMode,
|
||||
isHttp,
|
||||
sites,
|
||||
updateTarget,
|
||||
getDockerStateForSite,
|
||||
refreshContainersForSite,
|
||||
openHealthCheckDialog,
|
||||
removeTarget,
|
||||
allowedMethods,
|
||||
t
|
||||
]);
|
||||
|
||||
function addNewTarget() {
|
||||
const defaultMethod = providerId
|
||||
? (allowedMethods[0] ?? "https")
|
||||
: isHttp
|
||||
? "http"
|
||||
: null;
|
||||
const newTarget: LocalTarget = {
|
||||
targetId: -Date.now(),
|
||||
ip: "",
|
||||
mode: ((resource?.mode as LocalTarget["mode"]) ??
|
||||
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
|
||||
method: isHttp ? "http" : null,
|
||||
method: defaultMethod,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
@@ -595,7 +664,8 @@ export function ProxyResourceTargetsForm({
|
||||
rewritePathType: null,
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
resourceId: resource?.resourceId ?? 0,
|
||||
resourceId: resource?.resourceId ?? null,
|
||||
providerId: providerId ?? null,
|
||||
hcEnabled: false,
|
||||
hcPath: null,
|
||||
hcMethod: null,
|
||||
@@ -662,18 +732,25 @@ export function ProxyResourceTargetsForm({
|
||||
}, [sites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAdvancedMode) return;
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(
|
||||
"proxy-advanced-mode",
|
||||
isAdvancedMode.toString()
|
||||
);
|
||||
}
|
||||
}, [isAdvancedMode]);
|
||||
}, [isAdvancedMode, disableAdvancedMode]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||
const [, formAction, isSubmitting] = useActionState(
|
||||
async () => {
|
||||
await saveTargets();
|
||||
return null;
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const addTargetButton = (
|
||||
<Button onClick={addNewTarget} variant="outline">
|
||||
<Button type="button" onClick={addNewTarget} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
@@ -681,8 +758,8 @@ export function ProxyResourceTargetsForm({
|
||||
|
||||
const hasTargets = targets.length > 0;
|
||||
|
||||
async function saveTargets() {
|
||||
if (!resource) return;
|
||||
async function saveTargets(options?: { silent?: boolean }) {
|
||||
if (!isEditMode) return true;
|
||||
|
||||
const targetsWithInvalidFields = targets.filter(
|
||||
(target) =>
|
||||
@@ -698,7 +775,7 @@ export function ProxyResourceTargetsForm({
|
||||
title: t("targetErrorInvalidIp"),
|
||||
description: t("targetErrorInvalidIpDescription")
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -742,9 +819,12 @@ export function ProxyResourceTargetsForm({
|
||||
}
|
||||
|
||||
if (target.new) {
|
||||
const createPath = providerId
|
||||
? `/ai-provider/${providerId}/target`
|
||||
: `/resource/${resource!.resourceId}/target`;
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateTargetResponse>
|
||||
>(`/resource/${resource.resourceId}/target`, data);
|
||||
>(createPath, data);
|
||||
target.targetId = res.data.data.targetId;
|
||||
target.new = false;
|
||||
} else if (target.updated) {
|
||||
@@ -753,24 +833,33 @@ export function ProxyResourceTargetsForm({
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title:
|
||||
targets.length === 0
|
||||
? t("targetTargetsCleared")
|
||||
: t("settingsUpdated"),
|
||||
description:
|
||||
targets.length === 0
|
||||
? t("targetTargetsClearedDescription")
|
||||
: t("settingsUpdatedDescription")
|
||||
});
|
||||
if (!options?.silent) {
|
||||
toast({
|
||||
title:
|
||||
targets.length === 0
|
||||
? t("targetTargetsCleared")
|
||||
: t("settingsUpdated"),
|
||||
description:
|
||||
targets.length === 0
|
||||
? t("targetTargetsClearedDescription")
|
||||
: t("settingsUpdatedDescription")
|
||||
});
|
||||
}
|
||||
|
||||
setTargetsToRemove([]);
|
||||
router.refresh();
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
if (providerId) {
|
||||
await queryClient.invalidateQueries(
|
||||
aiProviderQueries.providerTargets({ providerId })
|
||||
);
|
||||
} else if (resource) {
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
@@ -781,151 +870,173 @@ export function ProxyResourceTargetsForm({
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: saveTargets
|
||||
}));
|
||||
|
||||
const advancedModeToggleId = providerId
|
||||
? `advanced-mode-toggle-provider-${providerId}`
|
||||
: resource
|
||||
? `advanced-mode-toggle-resource-${resource.resourceId}`
|
||||
: "advanced-mode-toggle";
|
||||
|
||||
const targetsTable = (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isActionsColumn =
|
||||
header.column.id === "actions";
|
||||
const isSiteColumn =
|
||||
header.column.id === "site";
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isActionsColumn =
|
||||
cell.column.id === "actions";
|
||||
const isSiteColumn =
|
||||
cell.column.id === "site";
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<DataTableEmptyState
|
||||
colSpan={columns.length}
|
||||
message={emptyMessage ?? t("targetNoOne")}
|
||||
action={addTargetButton}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{hasTargets && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{addTargetButton}
|
||||
{!disableAdvancedMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={advancedModeToggleId}
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor={advancedModeToggleId}
|
||||
className="text-sm"
|
||||
>
|
||||
{t("advancedMode")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" &&
|
||||
!isAiProvider &&
|
||||
targets.length > 1 &&
|
||||
new Set(targets.map((t) => t.siteId)).size > 1 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{build === "saas" && isAiProvider && hasRemoteExitNodes && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t("aiProviderRemoteNodeTargetsWarning")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("targets")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isActionsColumn =
|
||||
header.column.id === "actions";
|
||||
const isSiteColumn =
|
||||
header.column.id === "site";
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const isActionsColumn =
|
||||
cell.column.id ===
|
||||
"actions";
|
||||
const isSiteColumn =
|
||||
cell.column.id ===
|
||||
"site";
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<DataTableEmptyState
|
||||
colSpan={columns.length}
|
||||
message={t("targetNoOne")}
|
||||
action={addTargetButton}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{hasTargets && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{addTargetButton}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="advanced-mode-toggle"
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor="advanced-mode-toggle"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("advancedMode")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" &&
|
||||
targets.length > 1 &&
|
||||
new Set(targets.map((t) => t.siteId)).size > 1 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
{embedded ? (
|
||||
<div className="space-y-4">{targetsTable}</div>
|
||||
) : (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("targets")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>{targetsTable}</SettingsSectionBody>
|
||||
|
||||
{/* Save button — only shown in edit mode */}
|
||||
{resource && (
|
||||
<form className="self-end mt-4" action={formAction}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveResourceTargets")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</SettingsSection>
|
||||
{isEditMode && !hideSaveButton && (
|
||||
<form className="self-end mt-4" action={formAction}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveResourceTargets")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{selectedTargetForHealthCheck && (
|
||||
<HealthCheckCredenza
|
||||
@@ -986,4 +1097,4 @@ export function ProxyResourceTargetsForm({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
AiProviderAttachments,
|
||||
type AiProviderAttachmentValue
|
||||
} from "@app/components/AiProviderAttachments";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function PublicResourceInferencePage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const { resource } = useResourceContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (resource.mode !== "inference") {
|
||||
router.replace(
|
||||
`/${resource.orgId}/settings/resources/public/${resource.niceId}/general`
|
||||
);
|
||||
}
|
||||
}, [router, resource.mode, resource.niceId, resource.orgId]);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
providerId: z.number().int().positive(),
|
||||
niceId: z.string(),
|
||||
name: z.string(),
|
||||
accessMode: z.enum(["inherit", "select"]),
|
||||
enabled: z.boolean(),
|
||||
selectedModelIds: z.array(z.number().int().positive())
|
||||
})
|
||||
)
|
||||
}),
|
||||
[]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const attachedQuery = useQuery({
|
||||
...resourceQueries.resourceAiProviders({
|
||||
resourceId: resource.resourceId
|
||||
}),
|
||||
enabled: resource.mode === "inference"
|
||||
});
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
...resourceQueries.resourceAiModels({
|
||||
resourceId: resource.resourceId
|
||||
}),
|
||||
enabled: resource.mode === "inference"
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providers: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!attachedQuery.data) return;
|
||||
const hasSelect = attachedQuery.data.some(
|
||||
(provider) => provider.accessMode === "select"
|
||||
);
|
||||
if (hasSelect && modelsQuery.isLoading) return;
|
||||
|
||||
const modelsByProvider = new Map<number, number[]>();
|
||||
for (const model of modelsQuery.data ?? []) {
|
||||
if (model.listType !== "allow") continue;
|
||||
const existing = modelsByProvider.get(model.providerId) ?? [];
|
||||
existing.push(model.modelId);
|
||||
modelsByProvider.set(model.providerId, existing);
|
||||
}
|
||||
|
||||
form.reset({
|
||||
providers: attachedQuery.data.map((provider) => ({
|
||||
providerId: provider.providerId,
|
||||
niceId: provider.niceId,
|
||||
name: provider.name,
|
||||
accessMode: provider.accessMode,
|
||||
enabled: provider.enabled,
|
||||
selectedModelIds:
|
||||
provider.accessMode === "select"
|
||||
? (modelsByProvider.get(provider.providerId) ?? [])
|
||||
: []
|
||||
}))
|
||||
});
|
||||
}, [
|
||||
attachedQuery.data,
|
||||
modelsQuery.data,
|
||||
modelsQuery.isLoading,
|
||||
form
|
||||
]);
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
try {
|
||||
await api.post(`/resource/${resource.resourceId}/ai-providers`, {
|
||||
providers: data.providers.map((provider) => ({
|
||||
providerId: provider.providerId,
|
||||
accessMode: provider.accessMode,
|
||||
enabled: provider.enabled
|
||||
}))
|
||||
});
|
||||
|
||||
const selectProviders = data.providers.filter(
|
||||
(provider) => provider.accessMode === "select"
|
||||
);
|
||||
if (selectProviders.length > 0) {
|
||||
await api.post(`/resource/${resource.resourceId}/ai-models`, {
|
||||
models: selectProviders.flatMap((provider) =>
|
||||
provider.selectedModelIds.map((modelId) => ({
|
||||
modelId,
|
||||
listType: "allow" as const
|
||||
}))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.resourceAiProviders({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.resourceAiModels({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiResourceProvidersUpdated")
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiResourceProvidersErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("aiResourceProvidersErrorUpdate")
|
||||
)
|
||||
});
|
||||
}
|
||||
}, null);
|
||||
|
||||
if (resource.mode !== "inference") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providersLoading =
|
||||
attachedQuery.isLoading ||
|
||||
(attachedQuery.data?.some((p) => p.accessMode === "select") &&
|
||||
modelsQuery.isLoading);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiResourceProviders")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiResourceProvidersDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="public-resource-providers-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiResourceProviders"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<AiProviderAttachments
|
||||
orgId={
|
||||
resource.orgId
|
||||
}
|
||||
value={
|
||||
field.value as AiProviderAttachmentValue[]
|
||||
}
|
||||
disabled={
|
||||
providersLoading
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="public-resource-providers-form"
|
||||
loading={saveLoading}
|
||||
disabled={providersLoading || saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
import { BudgetsEditor } from "@app/components/BudgetsEditor";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function PublicResourceBudgetPage() {
|
||||
const { resource } = useResourceContext();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
if (resource.mode !== "inference") {
|
||||
router.replace(
|
||||
`/${resource.orgId}/settings/resources/public/${resource.niceId}/general`
|
||||
);
|
||||
}
|
||||
}, [router, resource.mode, resource.niceId, resource.orgId]);
|
||||
|
||||
if (resource.mode !== "inference") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<BudgetsEditor
|
||||
orgId={resource.orgId}
|
||||
scope={{ type: "resource", id: resource.resourceId }}
|
||||
title={t("resourceBudgetSettings")}
|
||||
description={t("resourceBudgetSettingsDescription")}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { build } from "@server/build";
|
||||
import { TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
@@ -114,14 +115,20 @@ export default function GeneralForm() {
|
||||
.refine(
|
||||
(data) => {
|
||||
// For non-HTTP resources, proxyPort should be defined
|
||||
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||
if (
|
||||
!["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resource.mode
|
||||
)
|
||||
) {
|
||||
return data.proxyPort !== undefined;
|
||||
}
|
||||
// For HTTP resources, proxyPort should be undefined
|
||||
return data.proxyPort === undefined;
|
||||
},
|
||||
{
|
||||
message: !["http", "ssh", "rdp", "vnc"].includes(resource.mode)
|
||||
message: !["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resource.mode
|
||||
)
|
||||
? "Port number is required for non-HTTP resources"
|
||||
: "Port number should not be set for HTTP resources",
|
||||
path: ["proxyPort"]
|
||||
@@ -153,7 +160,7 @@ export default function GeneralForm() {
|
||||
|
||||
let resourcePolicyId: number | null | undefined;
|
||||
|
||||
if (!["tcp", "udp"].includes(resource.mode)) {
|
||||
if (!["tcp", "udp", "inference"].includes(resource.mode)) {
|
||||
if (hasResourcePolicies || selectedSharedPolicyId === null) {
|
||||
resourcePolicyId = selectedSharedPolicyId;
|
||||
}
|
||||
@@ -249,6 +256,25 @@ export default function GeneralForm() {
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceGeneralDescription")}
|
||||
{resource.mode === "inference" ? (
|
||||
<>
|
||||
{" "}
|
||||
{t.rich(
|
||||
"resourceGeneralAiClientConfigDescription",
|
||||
{
|
||||
configLink: (chunks) => (
|
||||
<Link
|
||||
href={`/${resource.orgId}?openResource=${encodeURIComponent(resource.niceId)}&openResourceQuery=${encodeURIComponent(resource.name)}`}
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
@@ -339,7 +365,7 @@ export default function GeneralForm() {
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{!["http", "ssh", "rdp", "vnc"].includes(
|
||||
{!["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resource.mode
|
||||
) && (
|
||||
<SettingsFormCell span="half">
|
||||
@@ -393,13 +419,16 @@ export default function GeneralForm() {
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{["http", "ssh", "rdp", "vnc"].includes(
|
||||
{["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resource.mode
|
||||
) && (
|
||||
<SettingsFormCell span="full">
|
||||
<div id="resource-domain-picker">
|
||||
<DomainPicker
|
||||
allowWildcard={true}
|
||||
allowWildcard={
|
||||
resource.mode !==
|
||||
"inference"
|
||||
}
|
||||
key={
|
||||
resource.resourceId
|
||||
}
|
||||
@@ -453,9 +482,11 @@ export default function GeneralForm() {
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{ !["tcp", "udp"].includes(
|
||||
resource.mode
|
||||
) && !env.flags.disableEnterpriseFeatures && (
|
||||
{!["tcp", "udp", "inference"].includes(
|
||||
resource.mode
|
||||
) &&
|
||||
!env.flags
|
||||
.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
|
||||
@@ -82,18 +82,18 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(`${resource.mode}Settings`),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode}`
|
||||
}
|
||||
];
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(`${resource.mode}Settings`),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode === "inference" ? "ai-gateway" : resource.mode}`
|
||||
}
|
||||
];
|
||||
|
||||
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||
if (["http", "ssh", "rdp", "vnc", "inference"].includes(resource.mode)) {
|
||||
navItems.push(
|
||||
{
|
||||
title: t("authentication"),
|
||||
@@ -105,7 +105,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
}
|
||||
);
|
||||
|
||||
if (!env.flags.disableEnterpriseFeatures) {
|
||||
if (!env.flags.disableEnterpriseFeatures && resource.mode !== "inference") {
|
||||
navItems.push({
|
||||
title: t("maintenanceMode"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/maintenance`
|
||||
@@ -113,6 +113,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (resource.mode === "inference") {
|
||||
navItems.push({
|
||||
title: t("resourceBudgetSettings"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/budget`
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function ResourceMaintenancePage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintencePage);
|
||||
const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintenancePage);
|
||||
|
||||
const maintenanceModeTypeOptions: StrategyOption<
|
||||
"automatic" | "forced"
|
||||
@@ -180,7 +180,7 @@ export default function ResourceMaintenancePage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
|
||||
<PaidFeaturesAlert tiers={tierMatrix.maintenancePage} />
|
||||
<div
|
||||
className={
|
||||
isMaintenanceDisabled
|
||||
|
||||
@@ -55,11 +55,7 @@ export default function RdpSettingsPage(props: {
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const disabled = !isPaidUser(
|
||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||
);
|
||||
|
||||
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
|
||||
@@ -75,14 +71,10 @@ export default function RdpSettingsPage(props: {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
|
||||
/>
|
||||
<RdpServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
disabled={disabled}
|
||||
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
@@ -92,13 +84,11 @@ export default function RdpSettingsPage(props: {
|
||||
function RdpServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
disabled,
|
||||
targetsResponse
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
disabled: boolean;
|
||||
targetsResponse: ResourceTargetsResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
@@ -215,10 +205,6 @@ function RdpServerForm({
|
||||
{t("rdpServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
@@ -244,7 +230,6 @@ function RdpServerForm({
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,11 +75,7 @@ export default function SshSettingsPage(props: {
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const disabled = !isPaidUser(
|
||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||
);
|
||||
|
||||
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
|
||||
@@ -95,14 +91,10 @@ export default function SshSettingsPage(props: {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
|
||||
/>
|
||||
<SshServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
disabled={disabled}
|
||||
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
@@ -113,13 +105,11 @@ function SshServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource,
|
||||
disabled,
|
||||
targetsResponse
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
disabled: boolean;
|
||||
targetsResponse: ResourceTargetsResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
@@ -375,10 +365,6 @@ function SshServerForm({
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
@@ -530,7 +516,6 @@ function SshServerForm({
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,11 +55,7 @@ export default function VncSettingsPage(props: {
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const disabled = !isPaidUser(
|
||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||
);
|
||||
|
||||
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
|
||||
@@ -75,14 +71,10 @@ export default function VncSettingsPage(props: {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
|
||||
/>
|
||||
<VncServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
disabled={disabled}
|
||||
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
@@ -92,13 +84,11 @@ export default function VncSettingsPage(props: {
|
||||
function VncServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
disabled,
|
||||
targetsResponse
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
disabled: boolean;
|
||||
targetsResponse: ResourceTargetsResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
@@ -215,10 +205,6 @@ function VncServerForm({
|
||||
{t("vncServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
@@ -244,7 +230,6 @@ function VncServerForm({
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
DescribedSelect,
|
||||
type DescribedSelectOption
|
||||
} from "@app/components/DescribedSelect";
|
||||
import {
|
||||
StrategySelect,
|
||||
type StrategyOption
|
||||
@@ -72,6 +72,10 @@ import {
|
||||
LocalTarget,
|
||||
ProxyResourceTargetsForm
|
||||
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
||||
import {
|
||||
AiProvidersSelector,
|
||||
type SelectedAiProvider
|
||||
} from "@app/components/AiProvidersSelector";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -206,7 +210,7 @@ function createAddTargetSchema(t: TranslateFn) {
|
||||
);
|
||||
}
|
||||
|
||||
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
|
||||
|
||||
type CreateBgTargetFormValues = SshSettingsFormValues;
|
||||
|
||||
@@ -235,16 +239,11 @@ export default function Page() {
|
||||
// Resource type state
|
||||
const [resourceType, setResourceType] = useState<NewResourceType>("http");
|
||||
|
||||
const isBrowserGatewayType =
|
||||
resourceType === "ssh" ||
|
||||
resourceType === "rdp" ||
|
||||
resourceType === "vnc";
|
||||
const browserGatewayDisabled =
|
||||
isBrowserGatewayType &&
|
||||
!isPaidUser(tierMatrix[TierFeature.AdvancedPublicResources]);
|
||||
|
||||
// Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit)
|
||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||
const [selectedProviders, setSelectedProviders] = useState<
|
||||
SelectedAiProvider[]
|
||||
>([]);
|
||||
|
||||
// SSH-specific state
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
@@ -333,7 +332,7 @@ export default function Page() {
|
||||
!env.flags.disableEnterpriseFeatures;
|
||||
|
||||
const availableTypes = useMemo((): NewResourceType[] => {
|
||||
const base: NewResourceType[] = ["http"];
|
||||
const base: NewResourceType[] = ["http", "inference"];
|
||||
if (enterpriseModesAllowed) {
|
||||
base.push("ssh", "rdp", "vnc");
|
||||
}
|
||||
@@ -478,29 +477,41 @@ export default function Page() {
|
||||
? finalizeSubdomainSanitize(httpData.subdomain, true)
|
||||
: undefined;
|
||||
|
||||
const effectiveMode = isNative
|
||||
? "native"
|
||||
: standardDaemonLocation;
|
||||
const portVal = sshDaemonPortForm.getValues().authDaemonPort;
|
||||
const effectivePort =
|
||||
!isNative &&
|
||||
standardDaemonLocation === "remote" &&
|
||||
pamMode === "push" &&
|
||||
portVal
|
||||
? Number(portVal)
|
||||
: undefined;
|
||||
|
||||
Object.assign(payload, {
|
||||
subdomain: sanitizedSubdomain
|
||||
? toASCII(sanitizedSubdomain)
|
||||
: undefined,
|
||||
domainId: httpData.domainId,
|
||||
protocol: "tcp",
|
||||
mode: resourceType,
|
||||
pamMode,
|
||||
authDaemonMode: effectiveMode,
|
||||
authDaemonPort: effectivePort || undefined
|
||||
mode: resourceType
|
||||
});
|
||||
|
||||
if (resourceType === "inference") {
|
||||
Object.assign(payload, {
|
||||
aiProviders: selectedProviders.map((provider) => ({
|
||||
providerId: parseInt(provider.id, 10)
|
||||
}))
|
||||
});
|
||||
} else if (resourceType === "ssh") {
|
||||
const effectiveMode = isNative
|
||||
? "native"
|
||||
: standardDaemonLocation;
|
||||
const portVal =
|
||||
sshDaemonPortForm.getValues().authDaemonPort;
|
||||
const effectivePort =
|
||||
!isNative &&
|
||||
standardDaemonLocation === "remote" &&
|
||||
pamMode === "push" &&
|
||||
portVal
|
||||
? Number(portVal)
|
||||
: undefined;
|
||||
|
||||
Object.assign(payload, {
|
||||
pamMode,
|
||||
authDaemonMode: effectiveMode,
|
||||
authDaemonPort: effectivePort || undefined
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const tcpUdpData = tcpUdpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
@@ -529,7 +540,11 @@ export default function Page() {
|
||||
const newNiceId = res.data.data.niceId;
|
||||
setNiceId(newNiceId);
|
||||
|
||||
if (resourceType === "http") {
|
||||
if (resourceType === "inference") {
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/public/${newNiceId}/general`
|
||||
);
|
||||
} else if (resourceType === "http") {
|
||||
if (targets.length > 0) {
|
||||
try {
|
||||
for (const target of targets) {
|
||||
@@ -752,25 +767,45 @@ export default function Page() {
|
||||
}
|
||||
];
|
||||
|
||||
let typeLabels: Partial<Record<NewResourceType, string>> = {
|
||||
http: "HTTP",
|
||||
tcp: "TCP",
|
||||
udp: "UDP"
|
||||
const typeMeta: Record<
|
||||
NewResourceType,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
http: {
|
||||
title: t("createInternalResourceDialogModeHttp"),
|
||||
description: t("resourceTypeHttpDescription")
|
||||
},
|
||||
inference: {
|
||||
title: t("createInternalResourceDialogModeInference"),
|
||||
description: t("resourceTypeInferenceDescription")
|
||||
},
|
||||
ssh: {
|
||||
title: t("createInternalResourceDialogModeSsh"),
|
||||
description: t("resourceTypeSshDescription")
|
||||
},
|
||||
rdp: {
|
||||
title: t("rdpTitle"),
|
||||
description: t("resourceTypeRdpDescription")
|
||||
},
|
||||
vnc: {
|
||||
title: t("vncTitle"),
|
||||
description: t("resourceTypeVncDescription")
|
||||
},
|
||||
tcp: {
|
||||
title: t("createInternalResourceDialogTcp"),
|
||||
description: t("resourceTypeTcpDescription")
|
||||
},
|
||||
udp: {
|
||||
title: t("createInternalResourceDialogUdp"),
|
||||
description: t("resourceTypeUdpDescription")
|
||||
}
|
||||
};
|
||||
|
||||
if (enterpriseModesAllowed) {
|
||||
typeLabels = {
|
||||
...typeLabels,
|
||||
ssh: "SSH",
|
||||
rdp: "RDP",
|
||||
vnc: "VNC",
|
||||
};
|
||||
}
|
||||
|
||||
const typeOptions: OptionSelectOption<NewResourceType>[] =
|
||||
const typeOptions: DescribedSelectOption<NewResourceType>[] =
|
||||
availableTypes.map((type) => ({
|
||||
value: type,
|
||||
label: typeLabels[type] ?? type.toUpperCase()
|
||||
title: typeMeta[type].title,
|
||||
description: typeMeta[type].description
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -807,6 +842,35 @@ export default function Page() {
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{t("type")}
|
||||
</Label>
|
||||
<DescribedSelect<NewResourceType>
|
||||
options={typeOptions}
|
||||
value={resourceType}
|
||||
onChange={
|
||||
setResourceType
|
||||
}
|
||||
searchPlaceholder={t(
|
||||
"resourceTypeSearch"
|
||||
)}
|
||||
emptyMessage={t(
|
||||
"resourceTypeNotFound"
|
||||
)}
|
||||
placeholder={t(
|
||||
"noneSelected"
|
||||
)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"resourceTypeDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<Form {...baseForm}>
|
||||
<form
|
||||
@@ -852,27 +916,6 @@ export default function Page() {
|
||||
</Form>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("type")}
|
||||
</p>
|
||||
<OptionSelect<NewResourceType>
|
||||
options={typeOptions}
|
||||
value={resourceType}
|
||||
onChange={
|
||||
setResourceType
|
||||
}
|
||||
cols={6}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"resourceTypeDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
{isHttpResource && (
|
||||
<SettingsFormCell span="full">
|
||||
<Form {...httpForm}>
|
||||
@@ -885,7 +928,8 @@ export default function Page() {
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
allowWildcard={
|
||||
true
|
||||
resourceType !==
|
||||
"inference"
|
||||
}
|
||||
orgId={
|
||||
orgId as string
|
||||
@@ -1005,14 +1049,6 @@ export default function Page() {
|
||||
{/* SSH Server Section */}
|
||||
{resourceType === "ssh" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={
|
||||
tierMatrix[
|
||||
TierFeature
|
||||
.AdvancedPublicResources
|
||||
]
|
||||
}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshServer")}
|
||||
@@ -1021,14 +1057,7 @@ export default function Page() {
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={browserGatewayDisabled}
|
||||
className={
|
||||
browserGatewayDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
@@ -1267,21 +1296,12 @@ export default function Page() {
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* RDP Server Section */}
|
||||
{resourceType === "rdp" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={
|
||||
tierMatrix[
|
||||
TierFeature
|
||||
.AdvancedPublicResources
|
||||
]
|
||||
}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("rdpServer")}
|
||||
@@ -1290,14 +1310,6 @@ export default function Page() {
|
||||
{t("rdpServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={browserGatewayDisabled}
|
||||
className={
|
||||
browserGatewayDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...bgTargetForm}>
|
||||
@@ -1314,21 +1326,12 @@ export default function Page() {
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* VNC Server Section */}
|
||||
{resourceType === "vnc" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={
|
||||
tierMatrix[
|
||||
TierFeature
|
||||
.AdvancedPublicResources
|
||||
]
|
||||
}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("vncServer")}
|
||||
@@ -1337,14 +1340,7 @@ export default function Page() {
|
||||
{t("vncServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={browserGatewayDisabled}
|
||||
className={
|
||||
browserGatewayDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...bgTargetForm}>
|
||||
@@ -1361,7 +1357,6 @@ export default function Page() {
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
@@ -1376,6 +1371,51 @@ export default function Page() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{resourceType === "inference" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiResourceProviders")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"aiResourceProvidersDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t(
|
||||
"aiResourceProviders"
|
||||
)}
|
||||
</Label>
|
||||
<AiProvidersSelector
|
||||
orgId={
|
||||
orgId as string
|
||||
}
|
||||
selectedProviders={
|
||||
selectedProviders
|
||||
}
|
||||
onSelectProviders={(
|
||||
providers
|
||||
) => {
|
||||
setSelectedProviders(
|
||||
providers
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1429,7 +1469,10 @@ export default function Page() {
|
||||
}
|
||||
}}
|
||||
loading={createLoading}
|
||||
disabled={!areAllTargetsValid() || browserGatewayDisabled || createLoading}
|
||||
disabled={
|
||||
!areAllTargetsValid() ||
|
||||
createLoading
|
||||
}
|
||||
>
|
||||
{t("resourceCreate")}
|
||||
</Button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import PublicResourcesBanner from "@app/components/PublicResourcesBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { build } from "@server/build";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import type { GetOrgResponse } from "@server/routers/org";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||
@@ -38,6 +40,7 @@ export default async function ProxyResourcesPage(
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
let pagination: ListResourcesResponse["pagination"] = {
|
||||
@@ -59,29 +62,7 @@ export default async function ProxyResourcesPage(
|
||||
searchParams.get("siteId") ?? undefined
|
||||
);
|
||||
|
||||
let initialFilterSite: {
|
||||
siteId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
} | null = null;
|
||||
if (siteIdParam) {
|
||||
try {
|
||||
const siteRes = await internal.get(
|
||||
`/site/${siteIdParam}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
|
||||
if (s && s.orgId === params.orgId) {
|
||||
initialFilterSite = {
|
||||
siteId: s.siteId,
|
||||
name: s.name,
|
||||
type: s.type
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// leave null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
@@ -139,6 +120,34 @@ export default async function ProxyResourcesPage(
|
||||
health: (resource.health as ResourceRow["health"]) ?? undefined
|
||||
};
|
||||
});
|
||||
// Prefetched in one batched call so the table doesn't fire a separate
|
||||
// certificate request per visible row once it mounts on the client.
|
||||
const certDomains = Array.from(
|
||||
new Set(
|
||||
resourceRows
|
||||
.filter((r) => r.ssl && r.fullDomain)
|
||||
.map((r) => r.fullDomain as string)
|
||||
)
|
||||
);
|
||||
|
||||
let initialCertificates: GetBatchedCertificateResponse | undefined;
|
||||
if (build !== "oss" && certDomains.length > 0) {
|
||||
try {
|
||||
const certSearchParams = new URLSearchParams(
|
||||
certDomains.map((domain) => ["domains", domain])
|
||||
);
|
||||
const certRes = await internal.get<
|
||||
AxiosResponse<GetBatchedCertificateResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
initialCertificates = certRes.data.data;
|
||||
} catch {
|
||||
// leave undefined so each row falls back to fetching its own
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
@@ -157,7 +166,7 @@ export default async function ProxyResourcesPage(
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
initialFilterSite={initialFilterSite}
|
||||
initialCertificates={initialCertificates}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
|
||||
@@ -40,6 +40,8 @@ import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { productUpdatesQueries } from "@app/lib/queries";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -67,6 +69,11 @@ export default function CredentialsPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
|
||||
|
||||
// Fetch site defaults for wireguard sites to show in obfuscated config
|
||||
useEffect(() => {
|
||||
const fetchSiteDefaults = async () => {
|
||||
@@ -302,6 +309,7 @@ export default function CredentialsPage() {
|
||||
id={displayNewtId ?? "**********"}
|
||||
secret={displaySecret ?? "**************"}
|
||||
endpoint={env.app.dashboardUrl}
|
||||
version={newtVersion}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -345,7 +353,7 @@ export default function CredentialsPage() {
|
||||
text={generateObfuscatedWireGuardConfig(
|
||||
{
|
||||
subnet:
|
||||
site?.subnet ||
|
||||
site?.exitNodeSubnet ||
|
||||
siteDefaults?.subnet ||
|
||||
null,
|
||||
address:
|
||||
|
||||
@@ -56,6 +56,8 @@ import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { productUpdatesQueries } from "@app/lib/queries";
|
||||
|
||||
type SiteType = "newt" | "wireguard" | "local";
|
||||
|
||||
@@ -189,9 +191,14 @@ export default function Page() {
|
||||
const [wgConfig, setWgConfig] = useState("");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [newtVersion, setNewtVersion] = useState("latest");
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
const newtVersion =
|
||||
latestVersions?.data?.newt?.latestVersion ?? "latest";
|
||||
|
||||
const [siteDefaults, setSiteDefaults] =
|
||||
useState<PickSiteDefaultsResponse | null>(null);
|
||||
|
||||
@@ -302,45 +309,6 @@ export default function Page() {
|
||||
const load = async () => {
|
||||
setLoadingPage(true);
|
||||
|
||||
let currentNewtVersion = "latest";
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/fosrl/newt/releases/latest`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
t("newtErrorFetchReleases", {
|
||||
err: response.statusText
|
||||
})
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
currentNewtVersion = latestVersion;
|
||||
setNewtVersion(latestVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.error(t("newtErrorFetchTimeout"));
|
||||
} else {
|
||||
console.error(
|
||||
t("newtErrorFetchLatest", {
|
||||
err:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const generatedKeypair = generateKeypair();
|
||||
|
||||
const privateKey = generatedKeypair.privateKey;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import IdentityKeysSplash from "@app/components/IdentityKeysSplash";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Identity Keys"
|
||||
};
|
||||
|
||||
type IdentityKeysPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function IdentityKeysPage(props: IdentityKeysPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
return <IdentityKeysSplash orgId={params.orgId} />;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import VirtualApiKeysTable, {
|
||||
type VirtualApiKeyRow
|
||||
} from "@app/components/VirtualApiKeysTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Metadata } from "next";
|
||||
import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
|
||||
import type { ListUsersResponse } from "@server/routers/user";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Virtual Keys"
|
||||
};
|
||||
|
||||
type VirtualApiKeysTablePageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VirtualApiKeysTablePage(
|
||||
props: VirtualApiKeysTablePageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const cookieHeader = await authCookieHeader();
|
||||
const t = await getTranslations();
|
||||
|
||||
let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = [];
|
||||
let users: {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
}[] = [];
|
||||
let resources: {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
niceId: string;
|
||||
}[] = [];
|
||||
|
||||
try {
|
||||
const [keysRes, usersRes, resourcesRes] = await Promise.all([
|
||||
internal.get<AxiosResponse<ListVirtualApiKeysResponse>>(
|
||||
`/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`,
|
||||
cookieHeader
|
||||
),
|
||||
internal.get<AxiosResponse<ListUsersResponse>>(
|
||||
`/org/${params.orgId}/users?page=1&pageSize=1000`,
|
||||
cookieHeader
|
||||
),
|
||||
internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
`/org/${params.orgId}/resources?page=1&pageSize=1000`,
|
||||
cookieHeader
|
||||
)
|
||||
]);
|
||||
|
||||
keys = keysRes.data.data.virtualApiKeys ?? [];
|
||||
users = (usersRes.data.data.users ?? []).map((u) => ({
|
||||
userId: u.id,
|
||||
email: u.email ?? null,
|
||||
name: u.name ?? null,
|
||||
username: u.username ?? null
|
||||
}));
|
||||
resources = (resourcesRes.data.data.resources ?? []).map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
niceId: r.niceId
|
||||
}));
|
||||
} catch {
|
||||
// leave empty; page still renders
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
cookieHeader
|
||||
)
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
const userById = new Map(users.map((u) => [u.userId, u]));
|
||||
const resourceById = new Map(resources.map((r) => [r.resourceId, r]));
|
||||
|
||||
const rows: VirtualApiKeyRow[] = keys.map((key) => {
|
||||
const user = key.userId ? userById.get(key.userId) : undefined;
|
||||
const keyResources = key.resourceIds
|
||||
.map((id) => resourceById.get(id))
|
||||
.filter(Boolean) as {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
niceId: string;
|
||||
}[];
|
||||
|
||||
const resourceNames = key.allResources
|
||||
? t("virtualApiKeysAllResources")
|
||||
: keyResources.map((r) => r.name).join(", ") ||
|
||||
t("virtualApiKeysNoResources");
|
||||
|
||||
return {
|
||||
virtualApiKeyId: key.virtualApiKeyId,
|
||||
orgId: key.orgId,
|
||||
kind: key.kind,
|
||||
userId: key.userId,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
lastChars: key.lastChars,
|
||||
allResources: key.allResources,
|
||||
expiresAt: key.expiresAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
createdAt: key.createdAt,
|
||||
createdByUserId: key.createdByUserId,
|
||||
resourceIds: key.resourceIds,
|
||||
userName: user?.name ?? null,
|
||||
username: user?.username ?? null,
|
||||
userEmail: user?.email ?? null,
|
||||
resourceNames,
|
||||
resources: keyResources
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<OrgProvider org={org}>
|
||||
<VirtualApiKeysTable virtualApiKeys={rows} orgId={params.orgId} />
|
||||
</OrgProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type VirtualApiKeysListLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function VirtualApiKeysListLayout({
|
||||
children,
|
||||
params
|
||||
}: VirtualApiKeysListLayoutProps) {
|
||||
const { orgId } = await params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("virtualApiKeysTabIdentity"),
|
||||
href: `/${orgId}/settings/virtual-api-keys/identity`
|
||||
},
|
||||
{
|
||||
title: t("virtualApiKeysTabVirtual"),
|
||||
href: `/${orgId}/settings/virtual-api-keys/keys`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("virtualApiKeysTitle")}
|
||||
description={t("virtualApiKeysDescription")}
|
||||
/>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Virtual API Keys"
|
||||
};
|
||||
|
||||
type VirtualApiKeysIndexPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function VirtualApiKeysIndexPage(
|
||||
props: VirtualApiKeysIndexPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
redirect(`/${params.orgId}/settings/virtual-api-keys/identity`);
|
||||
}
|
||||
@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
import z from "zod";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Log In"
|
||||
@@ -29,8 +32,9 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||
|
||||
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
|
||||
|
||||
const isInvite = searchParams?.redirect?.includes("/invite");
|
||||
const forceLoginParam = searchParams?.forceLogin;
|
||||
@@ -85,19 +89,48 @@ export default async function Page(props: {
|
||||
(build === "enterprise" && env.app.identityProviderMode === "org");
|
||||
|
||||
let loginIdps: LoginFormIDP[] = [];
|
||||
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
||||
null;
|
||||
|
||||
if (!useSmartLogin) {
|
||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||
const idpsRes = await cache(
|
||||
async () =>
|
||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
||||
)();
|
||||
const idpsRes =
|
||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.type
|
||||
})) as LoginFormIDP[];
|
||||
}
|
||||
} else {
|
||||
if (lastUsedIdpCookie) {
|
||||
const lastUsedIdpSchema = z.object({
|
||||
orgId: z.string().optional(),
|
||||
idpId: z.number()
|
||||
});
|
||||
try {
|
||||
const persistedData = lastUsedIdpSchema.parse(
|
||||
JSON.parse(lastUsedIdpCookie.value)
|
||||
);
|
||||
|
||||
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
|
||||
`/idp/${persistedData.idpId}`
|
||||
);
|
||||
|
||||
const res = idpRes.data.data;
|
||||
|
||||
lastUsedIdpForSmartLogin = {
|
||||
idpId: res.idp.idpId,
|
||||
name: res.idp.name,
|
||||
variant: res.idpOidcConfig?.variant ?? res.idp.type,
|
||||
orgId: persistedData.orgId,
|
||||
lastUsed: true
|
||||
};
|
||||
} catch (error) {
|
||||
// the idp might not exist or the data is malformatted, skip this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
@@ -160,6 +193,10 @@ export default async function Page(props: {
|
||||
redirect={redirectUrl}
|
||||
forceLogin={forceLogin}
|
||||
defaultUser={defaultUser}
|
||||
inviteMode={isInvite}
|
||||
lastUsedIdp={
|
||||
isInvite ? null : lastUsedIdpForSmartLogin
|
||||
}
|
||||
orgSignIn={
|
||||
!isInvite &&
|
||||
(build === "saas" ||
|
||||
@@ -179,7 +216,7 @@ export default async function Page(props: {
|
||||
) : (
|
||||
<DashboardLoginForm
|
||||
redirect={redirectUrl}
|
||||
idps={loginIdps}
|
||||
idps={isInvite ? [] : loginIdps}
|
||||
forceLogin={forceLogin}
|
||||
showOrgLogin={
|
||||
!isInvite &&
|
||||
|
||||
@@ -13,6 +13,8 @@ import { redirect } from "next/navigation";
|
||||
import OrgLoginPage from "@app/components/OrgLoginPage";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import type { Metadata } from "next";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Organization Login"
|
||||
@@ -68,15 +70,22 @@ export default async function OrgAuthPage(props: {
|
||||
variant: idp.variant
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
const hasLoginPageBranding = await isOrgSubscribed(
|
||||
orgId,
|
||||
tierMatrix.loginPageBranding
|
||||
);
|
||||
|
||||
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||
try {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||
>(`/login-page-branding?orgId=${orgId}`);
|
||||
if (res.status === 200) {
|
||||
branding = res.data.data;
|
||||
}
|
||||
} catch (error) {}
|
||||
if (hasLoginPageBranding) {
|
||||
try {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||
>(`/login-page-branding?orgId=${orgId}`);
|
||||
if (res.status === 200) {
|
||||
branding = res.data.data;
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<OrgLoginPage
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
|
||||
import OrgLoginPage from "@app/components/OrgLoginPage";
|
||||
import type { Metadata } from "next";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Choose Organization"
|
||||
@@ -83,7 +84,10 @@ export default async function OrgAuthPage(props: {
|
||||
redirect(env.app.dashboardUrl);
|
||||
}
|
||||
|
||||
const subscribed = await isOrgSubscribed(loginPage.orgId);
|
||||
const subscribed = await isOrgSubscribed(
|
||||
loginPage.orgId,
|
||||
tierMatrix.loginPageDomain
|
||||
);
|
||||
|
||||
if (build === "saas" && !subscribed) {
|
||||
console.log(
|
||||
|
||||
@@ -27,6 +27,7 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -70,14 +71,28 @@ export default async function ResourceAuthPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const subscribed = await isOrgSubscribed(authInfo.orgId);
|
||||
const isInference = authInfo.mode === "inference";
|
||||
const keysPath = `/${authInfo.orgId}/resource/${authInfo.resourceGuid}/keys`;
|
||||
|
||||
const hasLoginPageDomain = await isOrgSubscribed(
|
||||
authInfo.orgId,
|
||||
tierMatrix.loginPageDomain
|
||||
);
|
||||
const hasOrgOidc = await isOrgSubscribed(
|
||||
authInfo.orgId,
|
||||
tierMatrix.orgOidc
|
||||
);
|
||||
const hasLoginPageBranding = await isOrgSubscribed(
|
||||
authInfo.orgId,
|
||||
tierMatrix.loginPageBranding
|
||||
);
|
||||
|
||||
const allHeaders = await headers();
|
||||
const host = allHeaders.get("host");
|
||||
|
||||
const expectedHost = env.app.dashboardUrl.split("//")[1];
|
||||
if (host !== expectedHost) {
|
||||
if (build === "saas" && !subscribed) {
|
||||
if (build === "saas" && !hasLoginPageDomain) {
|
||||
redirect(env.app.dashboardUrl);
|
||||
}
|
||||
|
||||
@@ -106,7 +121,10 @@ export default async function ResourceAuthPage(props: {
|
||||
const redirectPort = new URL(searchParams.redirect).port;
|
||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
||||
|
||||
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
|
||||
const wildcardMatchesRedirect = (
|
||||
wildcardDomain: string,
|
||||
host: string
|
||||
): boolean => {
|
||||
if (!wildcardDomain.startsWith("*.")) return false;
|
||||
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
|
||||
return host.endsWith(suffix) && host.length > suffix.length;
|
||||
@@ -144,7 +162,9 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
if (user && !user.emailVerified && env.flags.emailVerificationRequired) {
|
||||
redirect(
|
||||
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceGuid}`
|
||||
`/auth/verify-email?redirect=${encodeURIComponent(
|
||||
`/auth/resource/${authInfo.resourceGuid}`
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,6 +198,20 @@ export default async function ResourceAuthPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// Inference resources never establish a resource session on the inference
|
||||
// host. Authenticated users retrieve their virtual API key on the dashboard.
|
||||
if (isInference && user) {
|
||||
if (host !== expectedHost) {
|
||||
redirect(`/auth/org?redirect=${encodeURIComponent(keysPath)}`);
|
||||
} else {
|
||||
redirect(keysPath);
|
||||
}
|
||||
}
|
||||
|
||||
// After password/pincode/SSO, do not send the browser back to the
|
||||
// inference host (session alone cannot pass Badger). Land on keys instead.
|
||||
const postAuthRedirect = isInference ? keysPath : redirectUrl;
|
||||
|
||||
if (!hasAuth) {
|
||||
// no authentication so always go straight to the resource
|
||||
redirect(redirectUrl);
|
||||
@@ -218,17 +252,14 @@ export default async function ResourceAuthPage(props: {
|
||||
if (searchParams.token) {
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<AccessToken
|
||||
token={searchParams.token}
|
||||
resourceId={authInfo.resourceId}
|
||||
/>
|
||||
<AccessToken token={searchParams.token} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let loginIdps: LoginFormIDP[] = [];
|
||||
if (build === "saas" || env.app.identityProviderMode === "org") {
|
||||
if (subscribed) {
|
||||
if (hasOrgOidc) {
|
||||
const idpsRes = await cache(
|
||||
async () =>
|
||||
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
||||
@@ -262,7 +293,7 @@ export default async function ResourceAuthPage(props: {
|
||||
<AutoLoginHandler
|
||||
resourceId={authInfo.resourceId}
|
||||
skipToIdpId={authInfo.skipToIdpId}
|
||||
redirectUrl={redirectUrl}
|
||||
redirectUrl={postAuthRedirect}
|
||||
orgId={build === "saas" ? authInfo.orgId : undefined}
|
||||
/>
|
||||
);
|
||||
@@ -271,7 +302,7 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||
try {
|
||||
if (subscribed) {
|
||||
if (hasLoginPageBranding) {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||
>(`/login-page-branding?orgId=${authInfo.orgId}`);
|
||||
@@ -300,7 +331,7 @@ export default async function ResourceAuthPage(props: {
|
||||
name: authInfo.resourceName,
|
||||
id: authInfo.resourceId
|
||||
}}
|
||||
redirect={redirectUrl}
|
||||
redirect={postAuthRedirect}
|
||||
idps={loginIdps}
|
||||
orgId={build === "saas" ? authInfo.orgId : undefined}
|
||||
branding={
|
||||
|
||||
+84
-1
@@ -3,10 +3,12 @@ import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
BellRing,
|
||||
Bot,
|
||||
Boxes,
|
||||
Building2,
|
||||
Cable,
|
||||
ChartLine,
|
||||
Coins,
|
||||
Combine,
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
Logs,
|
||||
MessageSquare,
|
||||
MessagesSquare,
|
||||
MonitorUp,
|
||||
Plug,
|
||||
ReceiptText,
|
||||
@@ -25,11 +29,13 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
ShieldIcon,
|
||||
Sparkles,
|
||||
SquareMousePointer,
|
||||
TagIcon,
|
||||
TicketCheck,
|
||||
Unplug,
|
||||
User,
|
||||
UserCheck,
|
||||
UserCog,
|
||||
Users,
|
||||
Waypoints
|
||||
@@ -43,6 +49,7 @@ export type SidebarNavSection = {
|
||||
|
||||
export type OrgNavSectionsOptions = {
|
||||
isPrimaryOrg?: boolean;
|
||||
isServerAdmin?: boolean;
|
||||
};
|
||||
|
||||
// Merged from 'user-management-and-resources' branch
|
||||
@@ -51,6 +58,11 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
title: "sidebarAccount",
|
||||
href: "/{orgId}",
|
||||
icon: <LayoutGrid className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarMyApiKeys",
|
||||
href: "/{orgId}/keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -58,6 +70,27 @@ export const orgNavSections = (
|
||||
env?: Env,
|
||||
options?: OrgNavSectionsOptions
|
||||
): SidebarNavSection[] => [
|
||||
{
|
||||
heading: "sidebarOverview",
|
||||
items: [
|
||||
{
|
||||
title: "resourceSidebarLauncherTitle",
|
||||
href: "/{orgId}",
|
||||
icon: <LayoutGrid className="size-4 flex-none" />,
|
||||
exact: true
|
||||
},
|
||||
...(options?.isServerAdmin
|
||||
? [
|
||||
{
|
||||
title: "serverAdmin",
|
||||
href: "/admin",
|
||||
icon: <Server className="size-4 flex-none" />,
|
||||
exact: true
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "network",
|
||||
items: [
|
||||
@@ -175,7 +208,7 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarApprovals",
|
||||
href: "/{orgId}/settings/access/approvals",
|
||||
icon: <UserCog className="size-4 flex-none" />
|
||||
icon: <UserCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
@@ -186,6 +219,31 @@ export const orgNavSections = (
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "sidebarAiGateway",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarAiProviders",
|
||||
href: "/{orgId}/settings/ai-providers",
|
||||
icon: <Sparkles className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarVirtualApiKeys",
|
||||
href: "/{orgId}/settings/virtual-api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAi",
|
||||
href: "/{orgId}/settings/logs/ai",
|
||||
icon: <MessagesSquare className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAiUsage",
|
||||
href: "/{orgId}/settings/logs/ai-usage",
|
||||
icon: <Coins className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "sidebarOrganization",
|
||||
items: [
|
||||
@@ -471,6 +529,21 @@ export const commandBarNavSections = (
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "sidebarAiGateway",
|
||||
items: [
|
||||
{
|
||||
title: "commandAiProviders",
|
||||
href: "/{orgId}/settings/ai-providers",
|
||||
icon: <Sparkles className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandVirtualApiKeys",
|
||||
href: "/{orgId}/settings/virtual-api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "commandLogsAndAnalytics",
|
||||
items: [
|
||||
@@ -484,6 +557,16 @@ export const commandBarNavSections = (
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsAi",
|
||||
href: "/{orgId}/settings/logs/ai",
|
||||
icon: <Bot className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsAiUsage",
|
||||
href: "/{orgId}/settings/logs/ai-usage",
|
||||
icon: <Coins className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
|
||||
+9
-7
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
|
||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||
import { cookies } from "next/headers";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,17 +27,21 @@ export default async function Page(props: {
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||
|
||||
let complete = false;
|
||||
let complete: boolean | null = null; // null means "unknown" (request errored)
|
||||
try {
|
||||
const setupRes = await internal.get<
|
||||
AxiosResponse<InitialSetupCompleteResponse>
|
||||
>(`/auth/initial-setup-complete`, await authCookieHeader());
|
||||
complete = setupRes.data.data.complete;
|
||||
} catch (e) {}
|
||||
if (!complete) {
|
||||
} catch (e) {
|
||||
// Swallow errors (e.g. 429 rate limit, 500, network failure).
|
||||
// Only redirect to initial-setup when the server *confirms* setup
|
||||
// is incomplete (complete === false). If the request itself failed we
|
||||
// cannot tell, so fall through to the login redirect instead.
|
||||
}
|
||||
if (complete === false) {
|
||||
redirect("/auth/initial-setup");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user