diff --git a/src/app/[orgId]/settings/logs/streaming/HttpDestinationCredenza.tsx b/src/app/[orgId]/settings/logs/streaming/HttpDestinationCredenza.tsx
new file mode 100644
index 000000000..653b766b0
--- /dev/null
+++ b/src/app/[orgId]/settings/logs/streaming/HttpDestinationCredenza.tsx
@@ -0,0 +1,731 @@
+"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 { Textarea } from "@app/components/ui/textarea";
+import { Checkbox } from "@app/components/ui/checkbox";
+import { Plus, X } from "lucide-react";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { toast } from "@app/hooks/useToast";
+import { build } from "@server/build";
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+export type AuthType = "none" | "bearer" | "basic" | "custom";
+
+export interface HttpConfig {
+ name: string;
+ url: string;
+ authType: AuthType;
+ bearerToken?: string;
+ basicCredentials?: string;
+ customHeaderName?: string;
+ customHeaderValue?: string;
+ headers: Array<{ key: string; value: string }>;
+ useBodyTemplate: boolean;
+ bodyTemplate?: string;
+}
+
+export interface Destination {
+ destinationId: number;
+ orgId: string;
+ type: string;
+ config: string;
+ enabled: boolean;
+ sendAccessLogs: boolean;
+ sendActionLogs: boolean;
+ sendConnectionLogs: boolean;
+ sendRequestLogs: boolean;
+ createdAt: number;
+ updatedAt: number;
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+export const defaultHttpConfig = (): HttpConfig => ({
+ name: "",
+ url: "",
+ authType: "none",
+ bearerToken: "",
+ basicCredentials: "",
+ customHeaderName: "",
+ customHeaderValue: "",
+ headers: [],
+ useBodyTemplate: false,
+ bodyTemplate: ""
+});
+
+export function parseHttpConfig(raw: string): HttpConfig {
+ try {
+ return { ...defaultHttpConfig(), ...JSON.parse(raw) };
+ } catch {
+ return defaultHttpConfig();
+ }
+}
+
+// ── Headers editor ─────────────────────────────────────────────────────────────
+
+interface HeadersEditorProps {
+ headers: Array<{ key: string; value: string }>;
+ onChange: (headers: Array<{ key: string; value: string }>) => void;
+}
+
+function HeadersEditor({ headers, onChange }: HeadersEditorProps) {
+ const addRow = () => onChange([...headers, { key: "", value: "" }]);
+
+ const removeRow = (i: number) =>
+ onChange(headers.filter((_, idx) => idx !== i));
+
+ const updateRow = (i: number, field: "key" | "value", val: string) => {
+ const next = [...headers];
+ next[i] = { ...next[i], [field]: val };
+ onChange(next);
+ };
+
+ return (
+
+ {headers.length === 0 && (
+
+ No custom headers configured. Click "Add Header" to add
+ one.
+
+ )}
+ {headers.map((h, i) => (
+
+ updateRow(i, "key", e.target.value)}
+ placeholder="Header name"
+ className="flex-1"
+ />
+
+ updateRow(i, "value", e.target.value)
+ }
+ placeholder="Value"
+ className="flex-1"
+ />
+
+
+ ))}
+
+
+ );
+}
+
+// ── Component ──────────────────────────────────────────────────────────────────
+
+export interface HttpDestinationCredenzaProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ editing: Destination | null;
+ orgId: string;
+ onSaved: () => void;
+}
+
+export function HttpDestinationCredenza({
+ open,
+ onOpenChange,
+ editing,
+ orgId,
+ onSaved
+}: HttpDestinationCredenzaProps) {
+ const api = createApiClient(useEnvContext());
+
+ const [saving, setSaving] = useState(false);
+ const [cfg, setCfg] = useState(defaultHttpConfig());
+ 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 ? parseHttpConfig(editing.config) : defaultHttpConfig()
+ );
+ setSendAccessLogs(editing?.sendAccessLogs ?? false);
+ setSendActionLogs(editing?.sendActionLogs ?? false);
+ setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
+ setSendRequestLogs(editing?.sendRequestLogs ?? false);
+ }
+ }, [open, editing]);
+
+ const update = (patch: Partial) =>
+ setCfg((prev) => ({ ...prev, ...patch }));
+
+ const urlError: string | null = (() => {
+ const raw = cfg.url.trim();
+ if (!raw) return null;
+ try {
+ const parsed = new URL(raw);
+ if (
+ parsed.protocol !== "http:" &&
+ parsed.protocol !== "https:"
+ ) {
+ return "URL must use http or https";
+ }
+ if (build === "saas" && parsed.protocol !== "https:") {
+ return "HTTPS is required on cloud deployments";
+ }
+ return null;
+ } catch {
+ return "Enter a valid URL (e.g. https://example.com/webhook)";
+ }
+ })();
+
+ const isValid =
+ cfg.name.trim() !== "" &&
+ cfg.url.trim() !== "" &&
+ urlError === null;
+
+ async function handleSave() {
+ if (!isValid) return;
+ setSaving(true);
+ try {
+ const payload = {
+ type: "http",
+ config: JSON.stringify(cfg),
+ sendAccessLogs,
+ sendActionLogs,
+ sendConnectionLogs,
+ sendRequestLogs
+ };
+ if (editing) {
+ await api.post(
+ `/org/${orgId}/event-streaming-destination/${editing.destinationId}`,
+ payload
+ );
+ toast({ title: "Destination updated successfully" });
+ } else {
+ await api.put(
+ `/org/${orgId}/event-streaming-destination`,
+ payload
+ );
+ toast({ title: "Destination created successfully" });
+ }
+ onSaved();
+ onOpenChange(false);
+ } catch (e) {
+ toast({
+ variant: "destructive",
+ title: editing
+ ? "Failed to update destination"
+ : "Failed to create destination",
+ description: formatAxiosError(
+ e,
+ "An unexpected error occurred."
+ )
+ });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+
+ {editing
+ ? "Edit Destination"
+ : "Add HTTP Destination"}
+
+
+ {editing
+ ? "Update the configuration for this HTTP event streaming destination."
+ : "Configure a new HTTP endpoint to receive your organization's events."}
+
+
+
+
+
+ {/* ── Settings tab ────────────────────────────── */}
+
+ {/* Name */}
+
+
+
+ update({ name: e.target.value })
+ }
+ />
+
+
+ {/* URL */}
+
+
+
+ update({ url: e.target.value })
+ }
+ />
+ {urlError && (
+
+ {urlError}
+
+ )}
+
+
+ {/* Authentication */}
+
+
+
+
+ Choose how requests to your endpoint
+ are authenticated.
+
+
+
+
+ update({ authType: v as AuthType })
+ }
+ className="gap-2"
+ >
+ {/* None */}
+
+
+
+
+
+ Sends requests without an{" "}
+
+ Authorization
+ {" "}
+ header.
+
+
+
+
+ {/* Bearer */}
+
+
+
+
+
+
+ Adds an{" "}
+
+ Authorization: Bearer
+ <token>
+ {" "}
+ header to each request.
+
+
+ {cfg.authType === "bearer" && (
+
+ update({
+ bearerToken:
+ e.target.value
+ })
+ }
+ />
+ )}
+
+
+
+ {/* Basic */}
+
+
+
+
+
+
+ Adds an{" "}
+
+ Authorization: Basic
+ <credentials>
+ {" "}
+ header. Provide credentials
+ as{" "}
+
+ username:password
+
+ .
+
+
+ {cfg.authType === "basic" && (
+
+ update({
+ basicCredentials:
+ e.target.value
+ })
+ }
+ />
+ )}
+
+
+
+ {/* Custom */}
+
+
+
+
+
+
+ Specify a custom HTTP
+ header name and value for
+ authentication (e.g.{" "}
+
+ X-API-Key
+
+ ).
+
+
+ {cfg.authType === "custom" && (
+
+
+ update({
+ customHeaderName:
+ e.target
+ .value
+ })
+ }
+ className="flex-1"
+ />
+
+ update({
+ customHeaderValue:
+ e.target
+ .value
+ })
+ }
+ className="flex-1"
+ />
+
+ )}
+
+
+
+
+
+
+ {/* ── Headers tab ──────────────────────────────── */}
+
+
+
+
+ Add custom headers to every outgoing
+ request. Useful for static tokens or
+ custom{" "}
+
+ Content-Type
+
+ . By default,{" "}
+
+ Content-Type: application/json
+ {" "}
+ is sent.
+
+
+
update({ headers })}
+ />
+
+
+ {/* ── Body Template tab ─────────────────────────── */}
+
+
+
+
+ Control the JSON payload structure sent to
+ your endpoint. If disabled, a default JSON
+ object is sent for each event.
+
+
+
+
+
+ update({ useBodyTemplate: v })
+ }
+ />
+
+
+
+ {cfg.useBodyTemplate && (
+
+
+
+ )}
+
+
+ {/* ── Logs tab ──────────────────────────────────── */}
+
+
+
+
+ Choose which log types are forwarded to
+ this destination. Only enabled log types
+ will be streamed.
+
+
+
+
+
+
+ setSendAccessLogs(v === true)
+ }
+ className="mt-0.5"
+ />
+
+
+
+ Resource access attempts, including
+ authenticated and denied requests.
+
+
+
+
+
+
+ setSendActionLogs(v === true)
+ }
+ className="mt-0.5"
+ />
+
+
+
+ Administrative actions performed by
+ users within the organization.
+
+
+
+
+
+
+ setSendConnectionLogs(v === true)
+ }
+ className="mt-0.5"
+ />
+
+
+
+ Site and tunnel connection events,
+ including connects and
+ disconnects.
+
+
+
+
+
+
+ setSendRequestLogs(v === true)
+ }
+ className="mt-0.5"
+ />
+
+
+
+ HTTP request logs for proxied
+ resources, including method, path,
+ and response code.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/[orgId]/settings/logs/streaming/page.tsx b/src/app/[orgId]/settings/logs/streaming/page.tsx
index 84b201260..035ad98f8 100644
--- a/src/app/[orgId]/settings/logs/streaming/page.tsx
+++ b/src/app/[orgId]/settings/logs/streaming/page.tsx
@@ -21,49 +21,25 @@ import {
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 { Textarea } from "@app/components/ui/textarea";
-import { Checkbox } from "@app/components/ui/checkbox";
-import { Globe, Plus, X } from "lucide-react";
+import { Globe, MoreHorizontal, Plus } from "lucide-react";
import { AxiosResponse } from "axios";
import { build } from "@server/build";
import Image from "next/image";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from "@app/components/ui/dropdown-menu";
+import {
+ Destination,
+ HttpDestinationCredenza,
+ parseHttpConfig
+} from "./HttpDestinationCredenza";
-// ── Types ──────────────────────────────────────────────────────────────────────
-
-type AuthType = "none" | "bearer" | "basic" | "custom";
-
-interface HttpConfig {
- name: string;
- url: string;
- authType: AuthType;
- bearerToken?: string;
- basicCredentials?: string;
- customHeaderName?: string;
- customHeaderValue?: string;
- headers: Array<{ key: string; value: string }>;
- useBodyTemplate: boolean;
- bodyTemplate?: string;
-}
-
-interface Destination {
- destinationId: number;
- orgId: string;
- type: string;
- config: string;
- enabled: boolean;
- sendAccessLogs: boolean;
- sendActionLogs: boolean;
- sendConnectionLogs: boolean;
- sendRequestLogs: boolean;
- createdAt: number;
- updatedAt: number;
-}
+// ── Re-export Destination so the rest of the file can use it ──────────────────
interface ListDestinationsResponse {
destinations: Destination[];
@@ -74,104 +50,13 @@ interface ListDestinationsResponse {
};
}
-// ── Helpers ────────────────────────────────────────────────────────────────────
-
-const defaultConfig = (): HttpConfig => ({
- name: "",
- url: "",
- authType: "none",
- bearerToken: "",
- basicCredentials: "",
- customHeaderName: "",
- customHeaderValue: "",
- headers: [],
- useBodyTemplate: false,
- bodyTemplate: ""
-});
-
-function parseConfig(raw: string): HttpConfig {
- try {
- return { ...defaultConfig(), ...JSON.parse(raw) };
- } catch {
- return defaultConfig();
- }
-}
-
-// ── Headers editor ─────────────────────────────────────────────────────────────
-
-interface HeadersEditorProps {
- headers: Array<{ key: string; value: string }>;
- onChange: (headers: Array<{ key: string; value: string }>) => void;
-}
-
-function HeadersEditor({ headers, onChange }: HeadersEditorProps) {
- const addRow = () => onChange([...headers, { key: "", value: "" }]);
-
- const removeRow = (i: number) =>
- onChange(headers.filter((_, idx) => idx !== i));
-
- const updateRow = (
- i: number,
- field: "key" | "value",
- val: string
- ) => {
- const next = [...headers];
- next[i] = { ...next[i], [field]: val };
- onChange(next);
- };
-
- return (
-
- {headers.length === 0 && (
-
- No custom headers configured. Click "Add Header" to add one.
-
- )}
- {headers.map((h, i) => (
-
- updateRow(i, "key", e.target.value)}
- placeholder="Header name"
- className="flex-1"
- />
- updateRow(i, "value", e.target.value)}
- placeholder="Value"
- className="flex-1"
- />
-
-
- ))}
-
-
- );
-}
-
// ── Destination card ───────────────────────────────────────────────────────────
interface DestinationCardProps {
destination: Destination;
onToggle: (id: number, enabled: boolean) => void;
onEdit: (destination: Destination) => void;
+ onDelete: (destination: Destination) => void;
isToggling: boolean;
disabled?: boolean;
}
@@ -180,18 +65,22 @@ function DestinationCard({
destination,
onToggle,
onEdit,
+ onDelete,
isToggling,
disabled = false
}: DestinationCardProps) {
- const cfg = parseConfig(destination.config);
+ const cfg = parseHttpConfig(destination.config);
return (
{/* Top row: icon + name/type + toggle */}
-
-
+ {/* Squirkle icon: gray outer → white inner → black globe */}
+
@@ -219,17 +108,37 @@ function DestinationCard({
)}
- {/* Footer: edit button */}
-
+ {/* Footer: edit button + three-dots menu */}
+
+
+
+
+
+
+ onDelete(destination)}
+ >
+ Delete
+
+
+
);
@@ -237,23 +146,12 @@ function DestinationCard({
// ── Add destination card ───────────────────────────────────────────────────────
-function AddDestinationCard({
- onClick,
- disabled = false
-}: {
- onClick: () => void;
- disabled?: boolean;
-}) {
+function AddDestinationCard({ onClick }: { onClick: () => void }) {
return (