mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-27 06:24:56 +02:00
@@ -8,6 +8,8 @@ import ExitNodesTable, {
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Metadata } from "next";
|
||||
import { build } from "@server/build";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Remote Exit Nodes"
|
||||
@@ -22,6 +24,10 @@ export const dynamic = "force-dynamic";
|
||||
export default async function RemoteExitNodesPage(
|
||||
props: RemoteExitNodesPageProps
|
||||
) {
|
||||
if (build != "saas") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const params = await props.params;
|
||||
let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = [];
|
||||
try {
|
||||
|
||||
@@ -345,6 +345,7 @@ export default function AiProviderNetworkPage() {
|
||||
ref={targetsFormRef}
|
||||
orgId={orgId}
|
||||
isHttp
|
||||
isAiProvider
|
||||
providerId={provider.providerId}
|
||||
initialTargets={
|
||||
isTargetModeSaved ? remoteTargets : []
|
||||
|
||||
@@ -682,6 +682,7 @@ export default function CreateAiProviderPage() {
|
||||
<ProxyResourceTargetsForm
|
||||
orgId={orgId}
|
||||
isHttp
|
||||
isAiProvider
|
||||
onChange={(nextTargets) => {
|
||||
targetsRef.current = nextTargets;
|
||||
}}
|
||||
|
||||
@@ -298,101 +298,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAISessions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("logRetentionAISessionsLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
parseInt(value, 10)
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
@@ -774,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>
|
||||
|
||||
@@ -3,11 +3,13 @@ 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";
|
||||
@@ -15,6 +17,8 @@ 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";
|
||||
@@ -29,6 +33,7 @@ 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)",
|
||||
@@ -44,6 +49,7 @@ export default function AiSessionLogsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
@@ -133,7 +139,8 @@ export default function AiSessionLogsPage() {
|
||||
...logQueries.aiSessions({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
})
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
||||
@@ -645,6 +652,8 @@ export default function AiSessionLogsPage() {
|
||||
description={t("aiSessionLogsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAISessions === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
@@ -679,6 +688,9 @@ export default function AiSessionLogsPage() {
|
||||
pageSize={pageSize}
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -88,8 +88,8 @@ export default async function ClientResourcesPage(
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? null,
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||
|
||||
@@ -113,6 +113,8 @@ type ProxyResourceTargetsFormProps = {
|
||||
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 const ProxyResourceTargetsForm = forwardRef<
|
||||
@@ -131,7 +133,8 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
emptyMessage,
|
||||
embedded = false,
|
||||
hideSaveButton = false,
|
||||
disableAdvancedMode = false
|
||||
disableAdvancedMode = false,
|
||||
isAiProvider = false
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -259,6 +262,14 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
})
|
||||
);
|
||||
|
||||
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) => {
|
||||
@@ -972,6 +983,7 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
</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">
|
||||
@@ -988,6 +1000,11 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{build === "saas" && isAiProvider && hasRemoteExitNodes && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t("aiProviderRemoteNodeTargetsWarning")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function Page(props: {
|
||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.type
|
||||
variant: idp.variant ?? idp.type
|
||||
})) as LoginFormIDP[];
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
|
||||
openai_chat: "aiCapabilityOpenaiChat",
|
||||
openai_responses: "aiCapabilityOpenaiResponses",
|
||||
anthropic_messages: "aiCapabilityAnthropicMessages",
|
||||
v1_models: "aiCapabilityV1Models",
|
||||
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
|
||||
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
|
||||
google_generate_content: "aiCapabilityGoogleGenerateContent",
|
||||
|
||||
@@ -79,7 +79,7 @@ function MessageBubble({ message }: { message: NormalizedAiMessage }) {
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
className={`min-w-0 max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isTool
|
||||
@@ -108,7 +108,7 @@ function RawFallbackBlock({
|
||||
}) {
|
||||
const pretty = prettyRaw(raw);
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="min-w-0 rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
{pretty && unparsedLabel && (
|
||||
@@ -117,7 +117,7 @@ function RawFallbackBlock({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all text-xs text-muted-foreground">
|
||||
{pretty ?? noDataLabel}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@ export function AiSessionChatView({
|
||||
</Button>
|
||||
</div>
|
||||
{rawMode ? (
|
||||
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
<div className="flex min-w-0 max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionRequest")}
|
||||
raw={normalizedRequest}
|
||||
@@ -185,7 +185,7 @@ export function AiSessionChatView({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
<div className="flex min-w-0 max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
{hasRequestMessages ? (
|
||||
requestMessages!.map((message, i) => (
|
||||
<MessageBubble key={`req-${i}`} message={message} />
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface Destination {
|
||||
sendActionLogs: boolean;
|
||||
sendConnectionLogs: boolean;
|
||||
sendRequestLogs: boolean;
|
||||
sendAISessionLogs: boolean;
|
||||
lastError: string | null;
|
||||
lastErrorAt: number | null;
|
||||
createdAt: number;
|
||||
@@ -180,6 +181,7 @@ export function HttpDestinationCredenza({
|
||||
const [sendActionLogs, setSendActionLogs] = useState(false);
|
||||
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
|
||||
const [sendRequestLogs, setSendRequestLogs] = useState(false);
|
||||
const [sendAISessionLogs, setSendAISessionLogs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -190,6 +192,7 @@ export function HttpDestinationCredenza({
|
||||
setSendActionLogs(editing?.sendActionLogs ?? false);
|
||||
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
|
||||
setSendRequestLogs(editing?.sendRequestLogs ?? false);
|
||||
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
|
||||
}
|
||||
}, [open, editing]);
|
||||
|
||||
@@ -226,7 +229,8 @@ export function HttpDestinationCredenza({
|
||||
sendAccessLogs,
|
||||
sendActionLogs,
|
||||
sendConnectionLogs,
|
||||
sendRequestLogs
|
||||
sendRequestLogs,
|
||||
sendAISessionLogs
|
||||
};
|
||||
if (editing) {
|
||||
await api.post(
|
||||
@@ -778,6 +782,30 @@ export function HttpDestinationCredenza({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||
<Checkbox
|
||||
id="log-ai-session"
|
||||
checked={sendAISessionLogs}
|
||||
onCheckedChange={(v) =>
|
||||
setSendAISessionLogs(v === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="log-ai-session"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{t("httpDestAISessionLogsTitle")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t(
|
||||
"httpDestAISessionLogsDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
|
||||
@@ -313,6 +313,15 @@ export function LogDataTable<TData, TValue>({
|
||||
}
|
||||
}, [currentPage, table, isServerPagination]);
|
||||
|
||||
// Collapse any expanded rows whenever the page changes, since row ids
|
||||
// are reused across pages and would otherwise show the wrong content
|
||||
// in the same expanded position.
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
useEffect(() => {
|
||||
setExpandedRows(new Set());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageIndex]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
@@ -515,9 +524,13 @@ export function LogDataTable<TData, TValue>({
|
||||
}
|
||||
className="p-4 bg-muted/50"
|
||||
>
|
||||
{renderExpandedRow(
|
||||
row.original
|
||||
)}
|
||||
{/* w-0 min-w-full keeps this cell's content from */}
|
||||
{/* blowing out the table's auto column widths */}
|
||||
<div className="w-0 min-w-full">
|
||||
{renderExpandedRow(
|
||||
row.original
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ export function S3DestinationCredenza({
|
||||
const [sendActionLogs, setSendActionLogs] = useState(false);
|
||||
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
|
||||
const [sendRequestLogs, setSendRequestLogs] = useState(false);
|
||||
const [sendAISessionLogs, setSendAISessionLogs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -98,6 +99,7 @@ export function S3DestinationCredenza({
|
||||
setSendActionLogs(editing?.sendActionLogs ?? false);
|
||||
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
|
||||
setSendRequestLogs(editing?.sendRequestLogs ?? false);
|
||||
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
|
||||
}
|
||||
}, [open, editing]);
|
||||
|
||||
@@ -121,7 +123,8 @@ export function S3DestinationCredenza({
|
||||
sendAccessLogs,
|
||||
sendActionLogs,
|
||||
sendConnectionLogs,
|
||||
sendRequestLogs
|
||||
sendRequestLogs,
|
||||
sendAISessionLogs
|
||||
};
|
||||
if (editing) {
|
||||
await api.post(
|
||||
@@ -510,6 +513,30 @@ export function S3DestinationCredenza({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||
<Checkbox
|
||||
id="s3-log-ai-session"
|
||||
checked={sendAISessionLogs}
|
||||
onCheckedChange={(v) =>
|
||||
setSendAISessionLogs(v === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="s3-log-ai-session"
|
||||
className="cursor-pointer font-medium"
|
||||
>
|
||||
{t("httpDestAISessionLogsTitle")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t(
|
||||
"httpDestAISessionLogsDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
|
||||
@@ -49,6 +49,10 @@ const CLIENT_LOGOS = {
|
||||
opencode: {
|
||||
light: "/third-party/opencode-dark.svg",
|
||||
dark: "/third-party/opencode-light.svg"
|
||||
},
|
||||
gemini: {
|
||||
light: "/third-party/gemini-dark.svg",
|
||||
dark: "/third-party/gemini-light.svg"
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -65,7 +69,8 @@ export function AiClientConfigSection({
|
||||
const descriptions: Record<string, string> = {
|
||||
claude: t("aiClientConfigDescriptionClaude"),
|
||||
codex: t("aiClientConfigDescriptionCodex"),
|
||||
opencode: t("aiClientConfigDescriptionOpencode")
|
||||
opencode: t("aiClientConfigDescriptionOpencode"),
|
||||
gemini: t("aiClientConfigDescriptionGemini")
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -129,6 +129,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
|
||||
payload?.[0]?.payload?.day
|
||||
)
|
||||
}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -172,6 +173,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
|
||||
payload?.[0]?.payload?.day
|
||||
)
|
||||
}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,8 @@ export function buildSeriesFromData(
|
||||
export const currencyFormatter = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
});
|
||||
|
||||
export const compactNumberFormatter = new Intl.NumberFormat(undefined, {
|
||||
|
||||
+16
-10
@@ -135,6 +135,7 @@ type ChartTooltipContentProps = React.ComponentProps<"div"> & {
|
||||
labelKey?: string;
|
||||
color?: string;
|
||||
labelClassName?: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
@@ -155,7 +156,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey
|
||||
labelKey,
|
||||
valueFormatter
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -302,19 +304,23 @@ const ChartTooltipContent = React.forwardRef<
|
||||
item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
{item.value !== undefined && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{!isNaN(
|
||||
item.value as number
|
||||
)
|
||||
? new Intl.NumberFormat(
|
||||
navigator.language,
|
||||
{
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(
|
||||
item.value as number
|
||||
)
|
||||
? valueFormatter
|
||||
? valueFormatter(
|
||||
item.value as number
|
||||
)
|
||||
: new Intl.NumberFormat(
|
||||
navigator.language,
|
||||
{
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(
|
||||
item.value as number
|
||||
)
|
||||
: item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ export const AI_CAPABILITIES = [
|
||||
"openai_chat",
|
||||
"openai_responses",
|
||||
"anthropic_messages",
|
||||
"v1_models",
|
||||
"gemini_generate_content",
|
||||
"bedrock_model_invoke",
|
||||
"google_generate_content",
|
||||
|
||||
+68
-11
@@ -1,10 +1,16 @@
|
||||
export const AI_CLIENT_IDS = ["claude", "codex", "opencode"] as const;
|
||||
export const AI_CLIENT_IDS = [
|
||||
"claude",
|
||||
"codex",
|
||||
"opencode",
|
||||
"gemini"
|
||||
] as const;
|
||||
export type AiClientId = (typeof AI_CLIENT_IDS)[number];
|
||||
|
||||
export const AI_CLIENT_NAMES: Record<AiClientId, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
opencode: "OpenCode"
|
||||
opencode: "OpenCode",
|
||||
gemini: "Gemini CLI"
|
||||
};
|
||||
|
||||
/** Auth as supplied by callers: the real key isn't fetched yet. */
|
||||
@@ -43,8 +49,15 @@ export type AiClientGuide = {
|
||||
presets: AiConfigPreset[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder key for keyless (private/site) resources. Those resources need
|
||||
* no credential, but most clients refuse to start without *some* key set, so
|
||||
* they get an obviously-inert one rather than an omitted field.
|
||||
*/
|
||||
const KEYLESS_PLACEHOLDER_KEY = "none";
|
||||
|
||||
function keyValue(auth: AiClientAuth): string {
|
||||
return auth.mode === "keyed" ? auth.key : "-";
|
||||
return auth.mode === "keyed" ? auth.key : KEYLESS_PLACEHOLDER_KEY;
|
||||
}
|
||||
|
||||
function block(
|
||||
@@ -74,7 +87,7 @@ export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean {
|
||||
}
|
||||
|
||||
function buildCli(
|
||||
clientArg: "claude" | "codex" | "opencode",
|
||||
clientArg: "claude" | "codex" | "opencode" | "gemini",
|
||||
auth: AiClientAuth,
|
||||
resourceNiceId?: string
|
||||
): AiConfigBlock[] {
|
||||
@@ -126,7 +139,7 @@ function buildClaudeGuide(
|
||||
(key) =>
|
||||
[
|
||||
`export ANTHROPIC_BASE_URL=${endpoint}`,
|
||||
`export ANTHROPIC_API_KEY=${auth.mode === "keyed" ? key : "none"}`,
|
||||
`export ANTHROPIC_API_KEY=${key}`,
|
||||
"claude"
|
||||
].join("\n"),
|
||||
auth
|
||||
@@ -328,7 +341,7 @@ function buildOpencodeGuide(
|
||||
"More providers",
|
||||
() =>
|
||||
"OpenCode configures providers individually, so Anthropic and OpenAI are just the ones set up above. " +
|
||||
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and under auth.json if it needs an API key.',
|
||||
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and a matching key in auth.json.',
|
||||
auth,
|
||||
"steps"
|
||||
);
|
||||
@@ -342,10 +355,53 @@ function buildOpencodeGuide(
|
||||
id: "default",
|
||||
label: "Default",
|
||||
relation: "steps",
|
||||
blocks:
|
||||
auth.mode === "keyed"
|
||||
? [config, authFile, moreProviders]
|
||||
: [config, moreProviders]
|
||||
// auth.json is written even for keyless resources: OpenCode
|
||||
// refuses to start a provider with no key at all ("OpenAI API
|
||||
// key is missing"), so it gets the inert placeholder instead.
|
||||
blocks: [config, authFile, moreProviders]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeminiGuide(
|
||||
endpoint: string,
|
||||
auth: AiClientAuth,
|
||||
resourceNiceId?: string
|
||||
): AiClientGuide {
|
||||
const defaultEnv = block(
|
||||
"gemini-default-env",
|
||||
"~/.gemini/.env",
|
||||
(key) =>
|
||||
[
|
||||
`GOOGLE_GEMINI_BASE_URL=${endpoint}`,
|
||||
`GEMINI_API_KEY=${key}`
|
||||
].join("\n"),
|
||||
auth
|
||||
);
|
||||
|
||||
const defaultShell = block(
|
||||
"gemini-default-shell",
|
||||
"Shell",
|
||||
(key) =>
|
||||
[
|
||||
`export GOOGLE_GEMINI_BASE_URL=${endpoint}`,
|
||||
`export GEMINI_API_KEY=${key}`,
|
||||
"gemini"
|
||||
].join("\n"),
|
||||
auth
|
||||
);
|
||||
|
||||
return {
|
||||
id: "gemini",
|
||||
name: AI_CLIENT_NAMES.gemini,
|
||||
cli: buildCli("gemini", auth, resourceNiceId),
|
||||
presets: [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
relation: "options",
|
||||
blocks: [defaultEnv, defaultShell]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -361,7 +417,8 @@ const GUIDE_BUILDERS: Record<
|
||||
> = {
|
||||
claude: buildClaudeGuide,
|
||||
codex: buildCodexGuide,
|
||||
opencode: buildOpencodeGuide
|
||||
opencode: buildOpencodeGuide,
|
||||
gemini: buildGeminiGuide
|
||||
};
|
||||
|
||||
export function buildAiClientGuide(
|
||||
|
||||
@@ -38,12 +38,12 @@ export const AI_PROVIDER_DEFAULTS: Record<
|
||||
openai: {
|
||||
upstreamUrl: "https://api.openai.com/v1",
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses"]
|
||||
capabilities: ["openai_chat", "openai_responses", "v1_models"]
|
||||
},
|
||||
anthropic: {
|
||||
upstreamUrl: "https://api.anthropic.com",
|
||||
authType: "x-api-key",
|
||||
capabilities: ["anthropic_messages"]
|
||||
capabilities: ["anthropic_messages", "v1_models"]
|
||||
},
|
||||
googleGemini: {
|
||||
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||
@@ -63,7 +63,12 @@ export const AI_PROVIDER_DEFAULTS: Record<
|
||||
microsoftFoundry: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
||||
capabilities: [
|
||||
"openai_chat",
|
||||
"openai_responses",
|
||||
"anthropic_messages",
|
||||
"v1_models"
|
||||
]
|
||||
},
|
||||
openRouter: {
|
||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||
|
||||
@@ -43,8 +43,8 @@ export async function fetchSiteResourceByNiceId(
|
||||
aliasAddress: match.aliasAddress || null,
|
||||
siteNiceIds: match.siteNiceIds,
|
||||
niceId: match.niceId,
|
||||
tcpPortRangeString: match.tcpPortRangeString || null,
|
||||
udpPortRangeString: match.udpPortRangeString || null,
|
||||
tcpPortRangeString: match.tcpPortRangeString ?? null,
|
||||
udpPortRangeString: match.udpPortRangeString ?? null,
|
||||
disableIcmp: match.disableIcmp || false,
|
||||
authDaemonMode: match.authDaemonMode ?? null,
|
||||
authDaemonPort: match.authDaemonPort ?? null,
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||
import type { ListRolesResponse } from "@server/routers/role";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import type {
|
||||
@@ -330,6 +331,17 @@ export const orgQueries = {
|
||||
}
|
||||
}),
|
||||
|
||||
remoteExitNodes: ({ orgId }: { orgId: string }) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "REMOTE_EXIT_NODES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListRemoteExitNodesResponse>
|
||||
>(`/org/${orgId}/remote-exit-nodes`, { signal });
|
||||
return res.data.data.remoteExitNodes;
|
||||
}
|
||||
}),
|
||||
|
||||
labels: ({
|
||||
orgId,
|
||||
query,
|
||||
|
||||
Reference in New Issue
Block a user