Files
pangolin/src/components/S3DestinationCredenza.tsx
2026-05-08 16:27:40 -07:00

543 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { Switch } from "@app/components/ui/switch";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Checkbox } from "@app/components/ui/checkbox";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { Destination } from "@app/components/HttpDestinationCredenza";
// ── Types ──────────────────────────────────────────────────────────────────────
export type S3PayloadFormat = "json_array" | "ndjson" | "csv";
export interface S3Config {
name: string;
accessKeyId: string;
secretAccessKey: string;
region: string;
bucket: string;
prefix: string;
endpoint: string;
format: S3PayloadFormat;
gzip: boolean;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
export const defaultS3Config = (): S3Config => ({
name: "",
accessKeyId: "",
secretAccessKey: "",
region: "us-east-1",
bucket: "",
prefix: "",
endpoint: "",
format: "json_array",
gzip: false
});
export function parseS3Config(raw: string): S3Config {
try {
return { ...defaultS3Config(), ...JSON.parse(raw) };
} catch {
return defaultS3Config();
}
}
// ── Component ──────────────────────────────────────────────────────────────────
export interface S3DestinationCredenzaProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Destination | null;
orgId: string;
onSaved: () => void;
}
export function S3DestinationCredenza({
open,
onOpenChange,
editing,
orgId,
onSaved
}: S3DestinationCredenzaProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [saving, setSaving] = useState(false);
const [cfg, setCfg] = useState<S3Config>(defaultS3Config());
const [sendAccessLogs, setSendAccessLogs] = useState(false);
const [sendActionLogs, setSendActionLogs] = useState(false);
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
const [sendRequestLogs, setSendRequestLogs] = useState(false);
useEffect(() => {
if (open) {
setCfg(editing ? parseS3Config(editing.config) : defaultS3Config());
setSendAccessLogs(editing?.sendAccessLogs ?? false);
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
}
}, [open, editing]);
const update = (patch: Partial<S3Config>) =>
setCfg((prev) => ({ ...prev, ...patch }));
const isValid =
cfg.name.trim() !== "" &&
cfg.accessKeyId.trim() !== "" &&
cfg.secretAccessKey.trim() !== "" &&
cfg.region.trim() !== "" &&
cfg.bucket.trim() !== "";
async function handleSave() {
if (!isValid) return;
setSaving(true);
try {
const payload = {
type: "s3",
config: JSON.stringify(cfg),
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs
};
if (editing) {
await api.post(
`/org/${orgId}/event-streaming-destination/${editing.destinationId}`,
payload
);
toast({ title: t("s3DestUpdatedSuccess") });
} else {
await api.put(
`/org/${orgId}/event-streaming-destination`,
payload
);
toast({ title: t("s3DestCreatedSuccess") });
}
onSaved();
onOpenChange(false);
} catch (e) {
toast({
variant: "destructive",
title: editing
? t("s3DestUpdateFailed")
: t("s3DestCreateFailed"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setSaving(false);
}
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>
{editing ? t("S3DestEditTitle") : t("S3DestAddTitle")}
</CredenzaTitle>
<CredenzaDescription>
{editing
? t("S3DestEditDescription")
: t("S3DestAddDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{editing?.lastError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="break-words">
{editing.lastError}
</AlertDescription>
</Alert>
)}
<HorizontalTabs
clientSide
items={[
{ title: t("s3DestTabSettings"), href: "" },
{ title: t("s3DestTabFormat"), href: "" },
{ title: t("httpDestTabLogs"), href: "" }
]}
>
{/* ── Settings tab ────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="s3-name">
{t("s3DestNameLabel")}
</Label>
<Input
id="s3-name"
placeholder={t("s3DestNamePlaceholder")}
value={cfg.name}
onChange={(e) =>
update({ name: e.target.value })
}
/>
</div>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label htmlFor="s3-access-key-id">
{t("s3DestAccessKeyIdLabel")}
</Label>
<Input
id="s3-access-key-id"
placeholder="AKIAIOSFODNN7EXAMPLE"
value={cfg.accessKeyId}
onChange={(e) =>
update({
accessKeyId: e.target.value
})
}
autoComplete="off"
/>
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label htmlFor="s3-secret-key">
{t("s3DestSecretAccessKeyLabel")}
</Label>
<Input
id="s3-secret-key"
type="password"
placeholder={t(
"s3DestSecretAccessKeyPlaceholder"
)}
value={cfg.secretAccessKey}
onChange={(e) =>
update({
secretAccessKey: e.target.value
})
}
autoComplete="new-password"
/>
</div>
{/* Region */}
<div className="space-y-2">
<Label htmlFor="s3-region">
{t("s3DestRegionLabel")}
</Label>
<Input
id="s3-region"
placeholder="us-east-1"
value={cfg.region}
onChange={(e) =>
update({ region: e.target.value })
}
/>
</div>
{/* Bucket */}
<div className="space-y-2">
<Label htmlFor="s3-bucket">
{t("s3DestBucketLabel")}
</Label>
<Input
id="s3-bucket"
placeholder="my-logs-bucket"
value={cfg.bucket}
onChange={(e) =>
update({ bucket: e.target.value })
}
/>
</div>
{/* Prefix */}
<div className="space-y-2">
<Label htmlFor="s3-prefix">
{t("s3DestPrefixLabel")}
</Label>
<Input
id="s3-prefix"
placeholder="pangolin/logs"
value={cfg.prefix}
onChange={(e) =>
update({ prefix: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t("s3DestPrefixDescription")}
</p>
</div>
{/* Custom endpoint (optional for S3-compatible storage) */}
<div className="space-y-2">
<Label htmlFor="s3-endpoint">
{t("s3DestEndpointLabel")}
</Label>
<Input
id="s3-endpoint"
placeholder="https://s3.example.com"
value={cfg.endpoint}
onChange={(e) =>
update({ endpoint: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t("s3DestEndpointDescription")}
</p>
</div>
</div>
{/* ── Format tab ───────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
{/* Gzip compression toggle */}
<div className="flex items-start gap-3 rounded-md border p-3">
<Switch
id="s3-gzip"
checked={cfg.gzip}
onCheckedChange={(v) => update({ gzip: v })}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-gzip"
className="cursor-pointer font-medium"
>
{t("s3DestGzipLabel")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("s3DestGzipDescription")}
</p>
</div>
</div>
{/* Payload format selector */}
<div className="space-y-3">
<div>
<label className="font-medium block">
{t("s3DestFormatTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("s3DestFormatDescription")}
</p>
</div>
<RadioGroup
value={cfg.format}
onValueChange={(v) =>
update({
format: v as S3PayloadFormat
})
}
className="gap-2"
>
{/* JSON Array */}
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<RadioGroupItem
value="json_array"
className="mt-0.5"
/>
<div>
<p className="text-sm font-medium leading-none">
{t(
"httpDestFormatJsonArrayTitle"
)}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t(
"s3DestFormatJsonArrayDescription"
)}
</p>
</div>
</label>
{/* NDJSON */}
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<RadioGroupItem
value="ndjson"
className="mt-0.5"
/>
<div>
<p className="text-sm font-medium leading-none">
{t("httpDestFormatNdjsonTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t(
"s3DestFormatNdjsonDescription"
)}
</p>
</div>
</label>
{/* CSV */}
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<RadioGroupItem
value="csv"
className="mt-0.5"
/>
<div>
<p className="text-sm font-medium leading-none">
{t("s3DestFormatCsvTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t(
"s3DestFormatCsvDescription"
)}
</p>
</div>
</label>
</RadioGroup>
</div>
</div>
{/* ── Logs tab ──────────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestLogTypesTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestLogTypesDescription")}
</p>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-access"
checked={sendAccessLogs}
onCheckedChange={(v) =>
setSendAccessLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-access"
className="cursor-pointer font-medium"
>
{t("httpDestAccessLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAccessLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-action"
checked={sendActionLogs}
onCheckedChange={(v) =>
setSendActionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-action"
className="cursor-pointer font-medium"
>
{t("httpDestActionLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestActionLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-connection"
checked={sendConnectionLogs}
onCheckedChange={(v) =>
setSendConnectionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-connection"
className="cursor-pointer font-medium"
>
{t("httpDestConnectionLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t(
"httpDestConnectionLogsDescription"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-request"
checked={sendRequestLogs}
onCheckedChange={(v) =>
setSendRequestLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-request"
className="cursor-pointer font-medium"
>
{t("httpDestRequestLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t(
"httpDestRequestLogsDescription"
)}
</p>
</div>
</div>
</div>
</div>
</HorizontalTabs>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
type="button"
variant="outline"
disabled={saving}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleSave}
loading={saving}
disabled={!isValid || saving}
>
{editing
? t("s3DestSaveChanges")
: t("s3DestCreateDestination")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}