diff --git a/agent/Dockerfile b/agent/Dockerfile index d50a94d3..f1ca08b6 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -8,6 +8,7 @@ ARG VERSION=dev RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/TerrifiedBug/vectorflow/agent/internal/agent.Version=${VERSION}" -o /vf-agent . # Stage 2: Download Vector +# Keep this default in sync with src/lib/vector-version.ts (canonical source). FROM alpine:3.21 AS vector ARG VECTOR_VERSION=0.54.0 RUN apk add --no-cache curl && \ diff --git a/agent/install.sh b/agent/install.sh index f5507b18..1b740acb 100755 --- a/agent/install.sh +++ b/agent/install.sh @@ -11,6 +11,7 @@ VECTOR_DATA_DIR="/var/lib/vector" CONFIG_DIR="/etc/vectorflow" ENV_FILE="${CONFIG_DIR}/agent.env" SERVICE_NAME="vf-agent" +# Keep this in sync with src/lib/vector-version.ts (canonical source). VECTOR_VERSION="0.54.0" # Defaults diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index 880fcc4e..e31c905d 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -11,8 +11,9 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ pnpm install --frozen-lockfile # ---- Stage 2: Download Vector binary (cached unless VECTOR_VERSION changes) ---- +# Keep this default in sync with src/lib/vector-version.ts (canonical source). FROM alpine:3.21 AS vector -ARG VECTOR_VERSION=0.53.0 +ARG VECTOR_VERSION=0.54.0 RUN apk add --no-cache curl && \ curl -sSfL -o /tmp/vector.tar.gz \ "https://packages.timber.io/vector/${VECTOR_VERSION}/vector-${VECTOR_VERSION}-x86_64-unknown-linux-musl.tar.gz" && \ diff --git a/package.json b/package.json index 14dec53a..b24ca9d3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "diff": "^8.0.3", "dotenv": "^17.3.1", "ioredis": "^5.10.1", @@ -53,6 +54,7 @@ "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.3", + "react-day-picker": "^9.14.0", "react-dom": "19.2.3", "react-grid-layout": "^2.2.2", "react-hook-form": "^7.71.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85f3af51..eb5ea512 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 diff: specifier: ^8.0.3 version: 8.0.3 @@ -129,6 +132,9 @@ importers: react: specifier: 19.2.3 version: 19.2.3 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@19.2.3) react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) @@ -661,6 +667,9 @@ packages: '@dagrejs/graphlib@3.0.4': resolution: {integrity: sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -2956,6 +2965,10 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -4012,6 +4025,12 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -5963,6 +5982,12 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -7770,6 +7795,8 @@ snapshots: '@dagrejs/graphlib@3.0.4': {} + '@date-fns/tz@1.4.1': {} + '@discoveryjs/json-ext@0.5.7': {} '@dotenvx/dotenvx@1.52.0': @@ -10190,6 +10217,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@tabby_ai/hijri-converter@1.0.5': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -11289,6 +11318,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + debounce@1.2.1: {} debug@3.2.7: @@ -13339,6 +13372,14 @@ snapshots: defu: 6.1.6 destr: 2.0.5 + react-day-picker@9.14.0(react@19.2.3): + dependencies: + '@date-fns/tz': 1.4.1 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.3 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 diff --git a/src/app/(dashboard)/alerts/_components/outbound-webhooks-section.tsx b/src/app/(dashboard)/alerts/_components/outbound-webhooks-section.tsx new file mode 100644 index 00000000..711f8526 --- /dev/null +++ b/src/app/(dashboard)/alerts/_components/outbound-webhooks-section.tsx @@ -0,0 +1,890 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useTeamStore } from "@/stores/team-store"; +import { copyToClipboard } from "@/lib/utils"; +import { toast } from "sonner"; +import { + Plus, + Loader2, + Copy, + Trash2, + Webhook, + ShieldCheck, + Clock, + ChevronDown, + ChevronRight, + Play, + CheckCircle, + XCircle, + AlertCircle, + Pencil, + ToggleLeft, + ToggleRight, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Checkbox } from "@/components/ui/checkbox"; +import { EmptyState } from "@/components/empty-state"; +import { QueryError } from "@/components/query-error"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import type { AlertMetric } from "@/generated/prisma"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +/** + * Supported webhook event types with human-readable labels. + * Only the outbound-webhook-relevant subset of AlertMetric. + */ +const WEBHOOK_EVENT_TYPES: { value: AlertMetric; label: string; description: string }[] = [ + { + value: "deploy_completed" as AlertMetric, + label: "Deploy Completed", + description: "A pipeline was successfully deployed", + }, + { + value: "pipeline_crashed" as AlertMetric, + label: "Pipeline Crashed", + description: "A running pipeline process exited unexpectedly", + }, + { + value: "node_unreachable" as AlertMetric, + label: "Node Unreachable", + description: "A fleet node stopped sending heartbeats", + }, + { + value: "node_joined" as AlertMetric, + label: "Node Joined", + description: "A new fleet node enrolled", + }, + { + value: "node_left" as AlertMetric, + label: "Node Left", + description: "A fleet node was removed", + }, + { + value: "deploy_rejected" as AlertMetric, + label: "Deploy Rejected", + description: "A deployment request was rejected", + }, + { + value: "deploy_cancelled" as AlertMetric, + label: "Deploy Cancelled", + description: "A pending deployment was cancelled", + }, + { + value: "promotion_completed" as AlertMetric, + label: "Promotion Completed", + description: "A pipeline was promoted to another environment", + }, +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatRelativeTime(date: Date | string | null | undefined): string { + if (!date) return "Never"; + const d = typeof date === "string" ? new Date(date) : date; + const diffMs = Date.now() - d.getTime(); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "Just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + return `${Math.floor(diffHr / 24)}d ago`; +} + +function deliveryStatusBadge(status: string) { + switch (status) { + case "success": + return ( + + + Success + + ); + case "failed": + return ( + + + Failed + + ); + case "dead_letter": + return ( + + + Dead Letter + + ); + default: + return ( + + + Pending + + ); + } +} + +// ─── Delivery History Row ───────────────────────────────────────────────────── + +type DeliveryRecord = { + id: string; + eventType: AlertMetric; + status: string; + statusCode: number | null; + attemptNumber: number; + errorMessage: string | null; + requestedAt: Date; + completedAt: Date | null; + nextRetryAt: Date | null; +}; + +function DeliveryHistoryPanel({ + endpointId, + teamId, +}: { + endpointId: string; + teamId: string; +}) { + const trpc = useTRPC(); + const [skip, setSkip] = useState(0); + const take = 10; + + const query = useQuery( + trpc.webhookEndpoint.listDeliveries.queryOptions( + { webhookEndpointId: endpointId, teamId, take, skip }, + { enabled: !!endpointId }, + ), + ); + + const deliveries = (query.data?.deliveries ?? []) as DeliveryRecord[]; + const total = query.data?.total ?? 0; + + if (query.isError) { + return ( +
+ Failed to load delivery history. +
+ ); + } + + if (query.isLoading) { + return ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ); + } + + if (deliveries.length === 0) { + return ( +
+ No deliveries yet. Trigger a test delivery or wait for an event. +
+ ); + } + + return ( +
+ + + + Event + Status + HTTP + Attempt + Requested + Completed + + + + {deliveries.map((d) => { + const eventLabel = + WEBHOOK_EVENT_TYPES.find((e) => e.value === d.eventType)?.label ?? d.eventType; + return ( + + {eventLabel} + {deliveryStatusBadge(d.status)} + + {d.statusCode ?? "—"} + + + #{d.attemptNumber} + + + {formatRelativeTime(d.requestedAt)} + + + {d.completedAt ? formatRelativeTime(d.completedAt) : "—"} + + + ); + })} + +
+ {total > take && ( +
+ + {skip + 1}–{Math.min(skip + take, total)} of {total} + +
+ + +
+
+ )} +
+ ); +} + +// ─── Endpoint Row ───────────────────────────────────────────────────────────── + +type Endpoint = { + id: string; + name: string; + url: string; + eventTypes: AlertMetric[]; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +}; + +function EndpointRow({ + endpoint, + teamId, + onEdit, + onDelete, + onToggle, + onTest, + testPending, +}: { + endpoint: Endpoint; + teamId: string; + onEdit: (ep: Endpoint) => void; + onDelete: (ep: Endpoint) => void; + onToggle: (id: string) => void; + onTest: (id: string) => void; + testPending: boolean; +}) { + const [expanded, setExpanded] = useState(false); + + return ( + <> + + +
+
{endpoint.name}
+
+ {endpoint.url} +
+
+
+ +
+ {endpoint.eventTypes.map((et) => { + const label = WEBHOOK_EVENT_TYPES.find((e) => e.value === et)?.label ?? et; + return ( + + {label} + + ); + })} +
+
+ + + {endpoint.enabled ? "Enabled" : "Disabled"} + + + + {formatRelativeTime(endpoint.createdAt)} + + +
+ + + + + +
+
+
+ {expanded && ( + + +
+
+ Delivery History +
+ +
+
+
+ )} + + ); +} + +// ─── Create / Edit Dialog ───────────────────────────────────────────────────── + +function EndpointDialog({ + open, + onOpenChange, + teamId, + editTarget, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + teamId: string; + editTarget: Endpoint | null; + onSuccess: (secret: string | null) => void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const isEdit = !!editTarget; + + const [name, setName] = useState(editTarget?.name ?? ""); + const [url, setUrl] = useState(editTarget?.url ?? ""); + const [secret, setSecret] = useState(""); + const [selectedEvents, setSelectedEvents] = useState>( + new Set(editTarget?.eventTypes ?? []), + ); + + // Reset when dialog opens/closes or editTarget changes + function reset() { + setName(editTarget?.name ?? ""); + setUrl(editTarget?.url ?? ""); + setSecret(""); + setSelectedEvents(new Set(editTarget?.eventTypes ?? [])); + } + + const createMutation = useMutation( + trpc.webhookEndpoint.create.mutationOptions({ + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: trpc.webhookEndpoint.list.queryKey(), + }); + onOpenChange(false); + onSuccess((data as { secret?: string | null }).secret ?? null); + toast.success("Webhook endpoint created"); + }, + onError: (err) => { + toast.error(err.message || "Failed to create webhook endpoint", { duration: 6000 }); + }, + }), + ); + + const updateMutation = useMutation( + trpc.webhookEndpoint.update.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhookEndpoint.list.queryKey(), + }); + onOpenChange(false); + toast.success("Webhook endpoint updated"); + }, + onError: (err) => { + toast.error(err.message || "Failed to update webhook endpoint", { duration: 6000 }); + }, + }), + ); + + function toggleEvent(value: string) { + setSelectedEvents((prev) => { + const next = new Set(prev); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }); + } + + function handleSubmit() { + if (!name.trim() || !url.trim() || selectedEvents.size === 0) { + toast.error("Name, URL, and at least one event type are required", { duration: 6000 }); + return; + } + const eventTypes = Array.from(selectedEvents) as AlertMetric[]; + if (isEdit && editTarget) { + updateMutation.mutate({ + id: editTarget.id, + teamId, + name: name.trim(), + url: url.trim(), + eventTypes, + secret: secret.trim() || undefined, + }); + } else { + createMutation.mutate({ + teamId, + name: name.trim(), + url: url.trim(), + eventTypes, + secret: secret.trim() || undefined, + }); + } + } + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + { + if (!v) reset(); + onOpenChange(v); + }} + > + + + {isEdit ? "Edit Webhook Endpoint" : "Create Webhook Endpoint"} + + {isEdit + ? "Update the endpoint configuration. Leave the signing secret blank to keep the existing one." + : "Webhook deliveries are HMAC-SHA256 signed. The signing secret is shown once — store it securely."} + + + +
+ {/* Name */} +
+ + setName(e.target.value)} + /> +
+ + {/* URL */} +
+ + setUrl(e.target.value)} + /> +
+ + {/* Secret */} +
+ + setSecret(e.target.value)} + /> +
+ + {/* Event Types */} +
+ +
+ {WEBHOOK_EVENT_TYPES.map((evt) => ( + + ))} +
+
+
+ + + + + +
+
+ ); +} + +// ─── Secret Display Modal ───────────────────────────────────────────────────── + +function SecretModal({ + open, + secret, + onClose, +}: { + open: boolean; + secret: string | null; + onClose: () => void; +}) { + return ( + !v && onClose()}> + + + + + Signing Secret + + + Copy this secret now — it will not be shown again. Use it to verify + the webhook-signature header on incoming requests. + + +
+
+ {secret} +
+ +
+ + + +
+
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function OutboundWebhooksSection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { selectedTeamId } = useTeamStore(); + + const [createOpen, setCreateOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [secretModalSecret, setSecretModalSecret] = useState(null); + const [testingId, setTestingId] = useState(null); + + const listQuery = useQuery( + trpc.webhookEndpoint.list.queryOptions( + { teamId: selectedTeamId ?? "" }, + { enabled: !!selectedTeamId }, + ), + ); + + const toggleMutation = useMutation( + trpc.webhookEndpoint.toggleEnabled.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhookEndpoint.list.queryKey(), + }); + }, + onError: (err) => { + toast.error(err.message || "Failed to toggle endpoint", { duration: 6000 }); + }, + }), + ); + + const deleteMutation = useMutation( + trpc.webhookEndpoint.delete.mutationOptions({ + onSuccess: () => { + setDeleteTarget(null); + queryClient.invalidateQueries({ + queryKey: trpc.webhookEndpoint.list.queryKey(), + }); + toast.success("Webhook endpoint deleted"); + }, + onError: (err) => { + toast.error(err.message || "Failed to delete endpoint", { duration: 6000 }); + }, + }), + ); + + const testMutation = useMutation( + trpc.webhookEndpoint.testDelivery.mutationOptions({ + onSuccess: (result) => { + setTestingId(null); + if ((result as { success?: boolean }).success) { + toast.success("Test delivery sent successfully"); + } else { + toast.error(`Test delivery failed: ${(result as { error?: string }).error ?? "unknown error"}`, { duration: 6000 }); + } + }, + onError: (err) => { + setTestingId(null); + toast.error(err.message || "Test delivery failed", { duration: 6000 }); + }, + }), + ); + + function handleTest(id: string) { + if (!selectedTeamId) return; + setTestingId(id); + testMutation.mutate({ id, teamId: selectedTeamId }); + } + + function handleToggle(id: string) { + if (!selectedTeamId) return; + toggleMutation.mutate({ id, teamId: selectedTeamId }); + } + + const endpoints = (listQuery.data ?? []) as Endpoint[]; + + if (!selectedTeamId) { + return ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ); + } + + if (listQuery.isError) { + return ( + listQuery.refetch()} + /> + ); + } + + return ( +
+ {/* Header */} +
+

+ Send HMAC-signed event notifications to external systems +

+ +
+ + {/* Endpoints Table */} + + + + + Webhook Endpoints + + + Endpoints receive signed HTTP POST requests when subscribed events occur. + Expand a row to view delivery history. + + + + {listQuery.isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : endpoints.length === 0 ? ( + + ) : ( +
+ + + + Endpoint + Events + Status + Created + Actions + + + + {endpoints.map((ep) => ( + + ))} + +
+
+ )} +
+
+ + {/* Create dialog */} + { + if (secret) setSecretModalSecret(secret); + }} + /> + + {/* Edit dialog */} + {editTarget && ( + !v && setEditTarget(null)} + teamId={selectedTeamId ?? ""} + editTarget={editTarget} + onSuccess={() => {}} + /> + )} + + {/* Secret display modal */} + setSecretModalSecret(null)} + /> + + {/* Delete confirmation */} + !v && setDeleteTarget(null)} + title="Delete Webhook Endpoint" + description={`Are you sure you want to delete "${deleteTarget?.name}"? All delivery history will also be deleted.`} + confirmLabel="Delete" + variant="destructive" + onConfirm={() => { + if (deleteTarget && selectedTeamId) { + deleteMutation.mutate({ id: deleteTarget.id, teamId: selectedTeamId }); + } + }} + isPending={deleteMutation.isPending} + /> +
+ ); +} diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx index 0e884d20..b848333c 100644 --- a/src/app/(dashboard)/alerts/page.tsx +++ b/src/app/(dashboard)/alerts/page.tsx @@ -21,7 +21,9 @@ import { Skeleton } from "@/components/ui/skeleton"; import { AlertRulesSection } from "./_components/alert-rules-section"; import { NotificationChannelsSection } from "./_components/notification-channels-section"; import { WebhooksSection } from "./_components/webhooks-section"; +import { OutboundWebhooksSection } from "./_components/outbound-webhooks-section"; import { AlertHistorySection } from "./_components/alert-history-section"; +import { AnomalyHistorySection } from "./_components/anomaly-history-section"; import { CorrelatedAlertHistory } from "./_components/correlated-alert-history"; import { FailedDeliveriesSection } from "./_components/failed-deliveries-section"; @@ -134,6 +136,11 @@ export default function AlertsPage() { environmentId={selectedEnvironmentId} /> + {/* Team-scoped subscription firehose for events outside the + AlertRule → channel routing model. Lives here (rather than + under global settings) so all webhook configuration is in + one place. */} + @@ -158,9 +165,18 @@ export default function AlertsPage() { - +
+ + {/* Anomalies aren't part of AlertCorrelationGroup yet, but + they're peer signals during incident triage — surface them + directly under the grouped alerts so this view answers the + "what fired together" question for both alert types. */} + +
diff --git a/src/app/(dashboard)/audit/page.tsx b/src/app/(dashboard)/audit/page.tsx index 6b45e57b..42a0cc44 100644 --- a/src/app/(dashboard)/audit/page.tsx +++ b/src/app/(dashboard)/audit/page.tsx @@ -5,10 +5,13 @@ import { useSearchParams } from "next/navigation"; import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { ChevronDown, ChevronRight, Rocket, ScrollText, Search } from "lucide-react"; +import { format } from "date-fns"; +import type { DateRange } from "react-day-picker"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; +import { DateRangePicker } from "@/components/ui/date-range-picker"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, @@ -304,23 +307,21 @@ export default function AuditPage() { {/* Date range */}
- - setStartDate(e.target.value)} - className="w-[160px]" - /> -
-
- - setEndDate(e.target.value)} - className="w-[160px]" + + { + setStartDate(range?.from ? format(range.from, "yyyy-MM-dd") : ""); + setEndDate(range?.to ? format(range.to, "yyyy-MM-dd") : ""); + }} />
diff --git a/src/app/(dashboard)/library/shared-components/page.tsx b/src/app/(dashboard)/library/shared-components/page.tsx index 474a95a1..8db61672 100644 --- a/src/app/(dashboard)/library/shared-components/page.tsx +++ b/src/app/(dashboard)/library/shared-components/page.tsx @@ -23,6 +23,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; +import { NODE_KIND_META } from "@/lib/node-kind-colors"; import { EmptyState } from "@/components/empty-state"; import { QueryError } from "@/components/query-error"; @@ -49,21 +50,26 @@ function formatRelativeTime(date: Date | string | null | undefined): string { /* Kind styling */ /* ------------------------------------------------------------------ */ -const kindConfig: Record = { +// Library uses Prisma's UPPER-CASE enum; pipeline editor uses lowercase. Bridge +// to the shared NODE_KIND_META so both surfaces use the same node colors. +const kindConfig: Record = { SOURCE: { - label: "Sources", - badge: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300", - accent: "text-emerald-600 dark:text-emerald-400", + label: NODE_KIND_META.source.pluralLabel, + badge: cn(NODE_KIND_META.source.bgClass, NODE_KIND_META.source.fgClass), + accent: NODE_KIND_META.source.accentClass, + border: NODE_KIND_META.source.borderClass, }, TRANSFORM: { - label: "Transforms", - badge: "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300", - accent: "text-sky-600 dark:text-sky-400", + label: NODE_KIND_META.transform.pluralLabel, + badge: cn(NODE_KIND_META.transform.bgClass, NODE_KIND_META.transform.fgClass), + accent: NODE_KIND_META.transform.accentClass, + border: NODE_KIND_META.transform.borderClass, }, SINK: { - label: "Sinks", - badge: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", - accent: "text-orange-600 dark:text-orange-400", + label: NODE_KIND_META.sink.pluralLabel, + badge: cn(NODE_KIND_META.sink.bgClass, NODE_KIND_META.sink.fgClass), + accent: NODE_KIND_META.sink.accentClass, + border: NODE_KIND_META.sink.borderClass, }, }; @@ -155,6 +161,7 @@ export default function SharedComponentsPage() { count={group.items.length} accent={group.accent} badgeClass={group.badge} + borderClass={group.border} items={group.items} onItemClick={(id) => router.push(`/library/shared-components/${id}`)} /> @@ -185,6 +192,7 @@ function KindSection({ count, accent, badgeClass, + borderClass, items, onItemClick, }: { @@ -192,6 +200,7 @@ function KindSection({ count: number; accent: string; badgeClass: string; + borderClass: string; items: SharedComponentItem[]; onItemClick: (id: string) => void; }) { @@ -216,7 +225,10 @@ function KindSection({ {items.map((sc) => ( onItemClick(sc.id)} > diff --git a/src/app/(dashboard)/settings/_components/anomaly-detection-settings.tsx b/src/app/(dashboard)/settings/_components/anomaly-detection-settings.tsx index 9cd26fcf..48390070 100644 --- a/src/app/(dashboard)/settings/_components/anomaly-detection-settings.tsx +++ b/src/app/(dashboard)/settings/_components/anomaly-detection-settings.tsx @@ -99,7 +99,16 @@ export function AnomalyDetectionSettings() { const updateMutation = useMutation( trpc.settings.updateAnomalyConfig.mutationOptions({ - onSuccess: () => { + onSuccess: (result) => { + // Hydrate form state directly from the mutation response so the values + // can't be clobbered by a stale `settings` snapshot in the useEffect + // sync (the `dirty` toggle re-runs the effect before the refetch + // resolves, which previously made saves appear to revert). + setSigmaThreshold(result.anomalySigmaThreshold); + setBaselineWindowDays(result.anomalyBaselineWindowDays); + setDedupWindowHours(result.anomalyDedupWindowHours); + setMinStddevFloor(result.anomalyMinStddevFloorPercent); + setEnabledMetrics(parseEnabledMetrics(result.anomalyEnabledMetrics)); setDirty(false); queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); toast.success("Anomaly detection settings saved"); diff --git a/src/app/(dashboard)/settings/_components/backup-settings.tsx b/src/app/(dashboard)/settings/_components/backup-settings.tsx index 6b4cb50a..8a61135e 100644 --- a/src/app/(dashboard)/settings/_components/backup-settings.tsx +++ b/src/app/(dashboard)/settings/_components/backup-settings.tsx @@ -191,6 +191,30 @@ export function BackupSettings() { }), ); + // Download via fetch + blob so we can render JSON errors as toasts instead of + // letting the browser save the error body as `download.txt`. + async function handleDownload(filename: string) { + try { + const res = await fetch(`/api/backups/${encodeURIComponent(filename)}/download`); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + toast.error(data.error || `Download failed (${res.status})`, { duration: 6000 }); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Download failed", { duration: 6000 }); + } + } + if (settingsQuery.isError) return settingsQuery.refetch()} />; if (backupsQuery.isError) return backupsQuery.refetch()} />; @@ -535,14 +559,9 @@ export function BackupSettings() { - - - - )} - - ); -} - -// ─── Endpoint Row ───────────────────────────────────────────────────────────── - -type Endpoint = { - id: string; - name: string; - url: string; - eventTypes: AlertMetric[]; - enabled: boolean; - createdAt: Date; - updatedAt: Date; -}; - -function EndpointRow({ - endpoint, - teamId, - onEdit, - onDelete, - onToggle, - onTest, - testPending, -}: { - endpoint: Endpoint; - teamId: string; - onEdit: (ep: Endpoint) => void; - onDelete: (ep: Endpoint) => void; - onToggle: (id: string) => void; - onTest: (id: string) => void; - testPending: boolean; -}) { - const [expanded, setExpanded] = useState(false); - - return ( - <> - - -
-
{endpoint.name}
-
- {endpoint.url} -
-
-
- -
- {endpoint.eventTypes.map((et) => { - const label = WEBHOOK_EVENT_TYPES.find((e) => e.value === et)?.label ?? et; - return ( - - {label} - - ); - })} -
-
- - - {endpoint.enabled ? "Enabled" : "Disabled"} - - - - {formatRelativeTime(endpoint.createdAt)} - - -
- - - - - -
-
-
- {expanded && ( - - -
-
- Delivery History -
- -
-
-
- )} - - ); -} - -// ─── Create / Edit Dialog ───────────────────────────────────────────────────── - -function EndpointDialog({ - open, - onOpenChange, - teamId, - editTarget, - onSuccess, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - teamId: string; - editTarget: Endpoint | null; - onSuccess: (secret: string | null) => void; -}) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const isEdit = !!editTarget; - - const [name, setName] = useState(editTarget?.name ?? ""); - const [url, setUrl] = useState(editTarget?.url ?? ""); - const [secret, setSecret] = useState(""); - const [selectedEvents, setSelectedEvents] = useState>( - new Set(editTarget?.eventTypes ?? []), - ); - - // Reset when dialog opens/closes or editTarget changes - function reset() { - setName(editTarget?.name ?? ""); - setUrl(editTarget?.url ?? ""); - setSecret(""); - setSelectedEvents(new Set(editTarget?.eventTypes ?? [])); - } - - const createMutation = useMutation( - trpc.webhookEndpoint.create.mutationOptions({ - onSuccess: (data) => { - queryClient.invalidateQueries({ - queryKey: trpc.webhookEndpoint.list.queryKey(), - }); - onOpenChange(false); - onSuccess((data as { secret?: string | null }).secret ?? null); - toast.success("Webhook endpoint created"); - }, - onError: (err) => { - toast.error(err.message || "Failed to create webhook endpoint", { duration: 6000 }); - }, - }), - ); - - const updateMutation = useMutation( - trpc.webhookEndpoint.update.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.webhookEndpoint.list.queryKey(), - }); - onOpenChange(false); - toast.success("Webhook endpoint updated"); - }, - onError: (err) => { - toast.error(err.message || "Failed to update webhook endpoint", { duration: 6000 }); - }, - }), - ); - - function toggleEvent(value: string) { - setSelectedEvents((prev) => { - const next = new Set(prev); - if (next.has(value)) next.delete(value); - else next.add(value); - return next; - }); - } - - function handleSubmit() { - if (!name.trim() || !url.trim() || selectedEvents.size === 0) { - toast.error("Name, URL, and at least one event type are required", { duration: 6000 }); - return; - } - const eventTypes = Array.from(selectedEvents) as AlertMetric[]; - if (isEdit && editTarget) { - updateMutation.mutate({ - id: editTarget.id, - teamId, - name: name.trim(), - url: url.trim(), - eventTypes, - secret: secret.trim() || undefined, - }); - } else { - createMutation.mutate({ - teamId, - name: name.trim(), - url: url.trim(), - eventTypes, - secret: secret.trim() || undefined, - }); - } - } - - const isPending = createMutation.isPending || updateMutation.isPending; - - return ( - { - if (!v) reset(); - onOpenChange(v); - }} - > - - - {isEdit ? "Edit Webhook Endpoint" : "Create Webhook Endpoint"} - - {isEdit - ? "Update the endpoint configuration. Leave the signing secret blank to keep the existing one." - : "Webhook deliveries are HMAC-SHA256 signed. The signing secret is shown once — store it securely."} - - - -
- {/* Name */} -
- - setName(e.target.value)} - /> -
- - {/* URL */} -
- - setUrl(e.target.value)} - /> -
- - {/* Secret */} -
- - setSecret(e.target.value)} - /> -
- - {/* Event Types */} -
- -
- {WEBHOOK_EVENT_TYPES.map((evt) => ( - - ))} -
-
-
- - - - - -
-
- ); -} - -// ─── Secret Display Modal ───────────────────────────────────────────────────── - -function SecretModal({ - open, - secret, - onClose, -}: { - open: boolean; - secret: string | null; - onClose: () => void; -}) { - return ( - !v && onClose()}> - - - - - Signing Secret - - - Copy this secret now — it will not be shown again. Use it to verify - the webhook-signature header on incoming requests. - - -
-
- {secret} -
- -
- - - -
-
- ); -} - -// ─── Main Component ─────────────────────────────────────────────────────────── - -function WebhookEndpointsSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const { selectedTeamId } = useTeamStore(); - - const [createOpen, setCreateOpen] = useState(false); - const [editTarget, setEditTarget] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - const [secretModalSecret, setSecretModalSecret] = useState(null); - const [testingId, setTestingId] = useState(null); - - const listQuery = useQuery( - trpc.webhookEndpoint.list.queryOptions( - { teamId: selectedTeamId ?? "" }, - { enabled: !!selectedTeamId }, - ), - ); - - const toggleMutation = useMutation( - trpc.webhookEndpoint.toggleEnabled.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.webhookEndpoint.list.queryKey(), - }); - }, - onError: (err) => { - toast.error(err.message || "Failed to toggle endpoint", { duration: 6000 }); - }, - }), - ); - - const deleteMutation = useMutation( - trpc.webhookEndpoint.delete.mutationOptions({ - onSuccess: () => { - setDeleteTarget(null); - queryClient.invalidateQueries({ - queryKey: trpc.webhookEndpoint.list.queryKey(), - }); - toast.success("Webhook endpoint deleted"); - }, - onError: (err) => { - toast.error(err.message || "Failed to delete endpoint", { duration: 6000 }); - }, - }), - ); - - const testMutation = useMutation( - trpc.webhookEndpoint.testDelivery.mutationOptions({ - onSuccess: (result) => { - setTestingId(null); - if ((result as { success?: boolean }).success) { - toast.success("Test delivery sent successfully"); - } else { - toast.error(`Test delivery failed: ${(result as { error?: string }).error ?? "unknown error"}`, { duration: 6000 }); - } - }, - onError: (err) => { - setTestingId(null); - toast.error(err.message || "Test delivery failed", { duration: 6000 }); - }, - }), - ); - - function handleTest(id: string) { - if (!selectedTeamId) return; - setTestingId(id); - testMutation.mutate({ id, teamId: selectedTeamId }); - } - - function handleToggle(id: string) { - if (!selectedTeamId) return; - toggleMutation.mutate({ id, teamId: selectedTeamId }); - } - - const endpoints = (listQuery.data ?? []) as Endpoint[]; - - if (!selectedTeamId) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (listQuery.isError) { - return ( - listQuery.refetch()} - /> - ); - } - - return ( -
- {/* Header */} -
-

- Send HMAC-signed event notifications to external systems -

- -
- - {/* Endpoints Table */} - - - - - Webhook Endpoints - - - Endpoints receive signed HTTP POST requests when subscribed events occur. - Expand a row to view delivery history. - - - - {listQuery.isLoading ? ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ) : endpoints.length === 0 ? ( - - ) : ( -
- - - - Endpoint - Events - Status - Created - Actions - - - - {endpoints.map((ep) => ( - - ))} - -
-
- )} -
-
- - {/* Create dialog */} - { - if (secret) setSecretModalSecret(secret); - }} - /> - - {/* Edit dialog */} - {editTarget && ( - !v && setEditTarget(null)} - teamId={selectedTeamId ?? ""} - editTarget={editTarget} - onSuccess={() => {}} - /> - )} - - {/* Secret display modal */} - setSecretModalSecret(null)} - /> - - {/* Delete confirmation */} - !v && setDeleteTarget(null)} - title="Delete Webhook Endpoint" - description={`Are you sure you want to delete "${deleteTarget?.name}"? All delivery history will also be deleted.`} - confirmLabel="Delete" - variant="destructive" - onConfirm={() => { - if (deleteTarget && selectedTeamId) { - deleteMutation.mutate({ id: deleteTarget.id, teamId: selectedTeamId }); - } - }} - isPending={deleteMutation.isPending} - /> -
- ); -} - -// ─── Page wrapper ───────────────────────────────────────────────────────────── - -export default function WebhooksPage() { - return ( -
-
- -

Outbound Webhooks

-
- -
- ); +// Outbound webhook configuration moved to Alerts > Channels so all webhook +// surfaces live next to alert rules + notification channels. Anyone who has +// the old /settings/webhooks URL bookmarked lands in the new location. +export default function WebhooksRedirectPage() { + redirect("/alerts?tab=channels"); } diff --git a/src/app/api/backups/[filename]/download/route.ts b/src/app/api/backups/[filename]/download/route.ts index 0469aa52..8a7f04e0 100644 --- a/src/app/api/backups/[filename]/download/route.ts +++ b/src/app/api/backups/[filename]/download/route.ts @@ -16,13 +16,22 @@ function sanitizeFilename(filename: string): string { return base; } +// Always return JSON for errors so the client can render the message in a toast +// instead of the browser saving the error body as `download.txt`. +function jsonError(message: string, status: number): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + export async function GET( _request: Request, { params }: { params: Promise<{ filename: string }> } ) { const session = await auth(); if (!session?.user?.id) { - return new Response("Unauthorized", { status: 401 }); + return jsonError("Unauthorized", 401); } const user = await prisma.user.findUnique({ @@ -31,7 +40,7 @@ export async function GET( }); if (!user?.isSuperAdmin) { - return new Response("Forbidden", { status: 403 }); + return jsonError("Forbidden", 403); } const { filename } = await params; @@ -39,11 +48,11 @@ export async function GET( try { safe = sanitizeFilename(filename); } catch { - return new Response("Invalid filename", { status: 400 }); + return jsonError("Invalid filename", 400); } if (!safe.endsWith(".dump")) { - return new Response("Invalid backup filename", { status: 400 }); + return jsonError("Invalid backup filename", 400); } // Look up BackupRecord to determine storage location @@ -67,7 +76,7 @@ export async function GET( }); if (!settings?.s3Bucket || !settings?.s3AccessKeyId || !settings?.s3SecretAccessKey) { - return new Response("S3 not configured", { status: 500 }); + return jsonError("S3 not configured", 500); } const { decrypt } = await import("@/server/services/crypto"); @@ -89,7 +98,7 @@ export async function GET( })); if (!response.Body) { - return new Response("S3 object body is empty", { status: 500 }); + return jsonError("S3 object body is empty", 500); } const webStream = response.Body.transformToWebStream(); @@ -109,7 +118,7 @@ export async function GET( try { await fs.access(filePath); } catch { - return new Response("Backup not found", { status: 404 }); + return jsonError("Backup not found", 404); } const stat = await fs.stat(filePath); diff --git a/src/components/fleet/node-logs.tsx b/src/components/fleet/node-logs.tsx index 9a0fa7db..7c046935 100644 --- a/src/components/fleet/node-logs.tsx +++ b/src/components/fleet/node-logs.tsx @@ -153,9 +153,9 @@ export function NodeLogs({ nodeId, pipelines }: NodeLogsProps) { } return ( -
+
{/* Toolbar */} -
+
Level: {ALL_LEVELS.map((level) => ( + + + + + + ); +} diff --git a/src/hooks/__tests__/use-matrix-filters.test.ts b/src/hooks/__tests__/use-matrix-filters.test.ts index 82caef14..a219df19 100644 --- a/src/hooks/__tests__/use-matrix-filters.test.ts +++ b/src/hooks/__tests__/use-matrix-filters.test.ts @@ -19,11 +19,21 @@ describe("useMatrixFilters", () => { mockReplace.mockClear(); }); - it("returns default state when no URL params exist", () => { + it("defaults the status filter to 'running' when no URL params exist", () => { const { result } = renderHook(() => useMatrixFilters()); expect(result.current.search).toBe(""); - expect(result.current.statusFilter).toEqual([]); + expect(result.current.statusFilter).toEqual(["running"]); expect(result.current.tagFilter).toEqual([]); + // The default isn't considered an "active" filter so the + // clear-filters chip stays hidden until the user actually changes + // something. + expect(result.current.hasActiveFilters).toBe(false); + }); + + it("treats an explicit empty status param as 'show all'", () => { + currentSearchParams = new URLSearchParams("status="); + const { result } = renderHook(() => useMatrixFilters()); + expect(result.current.statusFilter).toEqual([]); expect(result.current.hasActiveFilters).toBe(false); }); @@ -77,7 +87,7 @@ describe("useMatrixFilters", () => { expect(params.get("tags")).toBe("prod,staging"); }); - it("clearFilters replaces with just the pathname", () => { + it("clearFilters drops all params but keeps an explicit status= override", () => { currentSearchParams = new URLSearchParams( "search=agent&status=deployed&tags=prod", ); @@ -88,6 +98,8 @@ describe("useMatrixFilters", () => { }); expect(mockReplace).toHaveBeenCalledTimes(1); - expect(mockReplace).toHaveBeenCalledWith("/test", { scroll: false }); + // status="" overrides the implicit "running" default. Without it the + // next render would silently re-apply the default. + expect(mockReplace).toHaveBeenCalledWith("/test?status=", { scroll: false }); }); }); diff --git a/src/hooks/use-matrix-filters.ts b/src/hooks/use-matrix-filters.ts index 308a0b8c..30c384e0 100644 --- a/src/hooks/use-matrix-filters.ts +++ b/src/hooks/use-matrix-filters.ts @@ -8,6 +8,10 @@ export interface MatrixFilters { tagFilter: string[]; } +// Default status filter applied when the URL has no `status` param at all. +// An explicit empty `?status=` overrides the default (means "show all"). +const DEFAULT_STATUS_FILTER: readonly string[] = ["running"] as const; + /** URL-synced filter state hook for the deployment matrix. */ export function useMatrixFilters() { const searchParams = useSearchParams(); @@ -15,8 +19,13 @@ export function useMatrixFilters() { const pathname = usePathname(); const search = searchParams.get("search") ?? ""; + + const statusParam = searchParams.get("status"); const statusFilter = - searchParams.get("status")?.split(",").filter(Boolean) ?? []; + statusParam === null + ? [...DEFAULT_STATUS_FILTER] + : statusParam.split(",").filter(Boolean); + const tagFilter = searchParams.get("tags")?.split(",").filter(Boolean) ?? []; @@ -39,7 +48,9 @@ export function useMatrixFilters() { if (statuses.length > 0) { params.set("status", statuses.join(",")); } else { - params.delete("status"); + // Explicit empty overrides the "running" default; otherwise the next + // render would silently re-apply the default. + params.set("status", ""); } router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }, @@ -60,11 +71,17 @@ export function useMatrixFilters() { ); const clearFilters = useCallback(() => { - router.replace(pathname, { scroll: false }); + // Status="" is the explicit override (otherwise the default re-applies). + router.replace(`${pathname}?status=`, { scroll: false }); }, [router, pathname]); + // The "running" default is not considered an active filter — the chip/banner + // shouldn't appear until the user has actually changed something. + const isStatusAtDefault = statusParam === null; const hasActiveFilters = - search.length > 0 || statusFilter.length > 0 || tagFilter.length > 0; + search.length > 0 || + (statusFilter.length > 0 && !isStatusAtDefault) || + tagFilter.length > 0; return { search, diff --git a/src/lib/node-kind-colors.ts b/src/lib/node-kind-colors.ts new file mode 100644 index 00000000..8977cc36 --- /dev/null +++ b/src/lib/node-kind-colors.ts @@ -0,0 +1,56 @@ +// Shared color tokens for source/transform/sink nodes. Used by the pipeline +// editor palette and the shared-components library so both surfaces match. + +export type NodeKind = "source" | "transform" | "sink"; + +export interface NodeKindMeta { + label: string; // singular ("Source") + pluralLabel: string; // plural ("Sources") + borderClass: string; // left-border accent for cards + bgClass: string; // solid color background (icon tiles, badges) + bgGlowClass: string; // soft 30% background (subtle fills) + fgClass: string; // foreground text color on solid background + accentClass: string; // text accent on neutral background +} + +export const NODE_KIND_META: Record = { + source: { + label: "Source", + pluralLabel: "Sources", + borderClass: "border-l-node-source", + bgClass: "bg-node-source", + bgGlowClass: "bg-node-source-glow", + fgClass: "text-node-source-foreground", + accentClass: "text-node-source", + }, + transform: { + label: "Transform", + pluralLabel: "Transforms", + borderClass: "border-l-node-transform", + bgClass: "bg-node-transform", + bgGlowClass: "bg-node-transform-glow", + fgClass: "text-node-transform-foreground", + accentClass: "text-node-transform", + }, + sink: { + label: "Sink", + pluralLabel: "Sinks", + borderClass: "border-l-node-sink", + bgClass: "bg-node-sink", + bgGlowClass: "bg-node-sink-glow", + fgClass: "text-node-sink-foreground", + accentClass: "text-node-sink", + }, +}; + +export const NODE_KIND_ORDER: readonly NodeKind[] = ["source", "transform", "sink"] as const; + +// Library/Prisma uses upper-case enum (`SOURCE`/`TRANSFORM`/`SINK`); pipeline +// editor uses the lowercase form. This normalises either to the canonical kind. +export function toNodeKind(value: string): NodeKind { + const lower = value.toLowerCase(); + if (lower === "source" || lower === "transform" || lower === "sink") { + return lower; + } + return "source"; +} diff --git a/src/lib/vector-version.ts b/src/lib/vector-version.ts new file mode 100644 index 00000000..606b0906 --- /dev/null +++ b/src/lib/vector-version.ts @@ -0,0 +1,9 @@ +// Single source of truth for the version of vector.dev that VectorFlow ships +// in its server image and pre-installs on agent nodes. When bumping, update +// the matching `ARG VECTOR_VERSION=` defaults in: +// - docker/server/Dockerfile +// - agent/Dockerfile +// - agent/install.sh +// (a CI check could be added later to enforce the cross-file alignment). + +export const VECTOR_VERSION = "0.54.0"; diff --git a/src/server/routers/__tests__/audit.test.ts b/src/server/routers/__tests__/audit.test.ts index c0cda642..bf7552ae 100644 --- a/src/server/routers/__tests__/audit.test.ts +++ b/src/server/routers/__tests__/audit.test.ts @@ -203,18 +203,45 @@ describe("audit.list", () => { ); }); - it("uses empty where clause when no filters are provided", async () => { + it("excludes SCIM provisioning actions from the default view", async () => { prismaMock.auditLog.findMany.mockResolvedValue([]); await caller.list({}); expect(prismaMock.auditLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: {}, + where: { + AND: [{ NOT: { action: { startsWith: "scim." } } }], + }, }), ); }); + it("includes SCIM entries when filtering by a SCIM entity type", async () => { + prismaMock.auditLog.findMany.mockResolvedValue([]); + + await caller.list({ entityTypes: ["ScimUser", "ScimGroup"] }); + + const call = prismaMock.auditLog.findMany.mock.calls[0]?.[0]; + const conditions = (call as { where: { AND: unknown[] } }).where.AND; + // No NOT-startsWith-scim condition should be present + expect(conditions).not.toContainEqual({ + NOT: { action: { startsWith: "scim." } }, + }); + }); + + it("includes SCIM entries when explicitly filtering by a scim.* action", async () => { + prismaMock.auditLog.findMany.mockResolvedValue([]); + + await caller.list({ action: "scim.user_created" }); + + const call = prismaMock.auditLog.findMany.mock.calls[0]?.[0]; + const conditions = (call as { where: { AND: unknown[] } }).where.AND; + expect(conditions).not.toContainEqual({ + NOT: { action: { startsWith: "scim." } }, + }); + }); + it("passes cursor for pagination", async () => { prismaMock.auditLog.findMany.mockResolvedValue([]); diff --git a/src/server/routers/audit.ts b/src/server/routers/audit.ts index 02d226c0..80cd9f69 100644 --- a/src/server/routers/audit.ts +++ b/src/server/routers/audit.ts @@ -91,6 +91,19 @@ export const auditRouter = router({ }); } + // Hide noisy SCIM provisioning entries from the default view. Users can + // still see them by selecting a SCIM entity type from the entity filter + // or by searching for "scim". This keeps the default view focused on + // operator-actioned events. Failures (when SCIM logging captures them) + // can be opted into via the same entity-type filter. + const isScimFilter = + action?.startsWith("scim.") || + entityTypes?.some((t) => t === "ScimUser" || t === "ScimGroup") || + search?.toLowerCase().includes("scim"); + if (!isScimFilter) { + conditions.push({ NOT: { action: { startsWith: "scim." } } }); + } + const where = conditions.length > 0 ? { AND: conditions } : {}; const items = await prisma.auditLog.findMany({ diff --git a/src/server/services/__tests__/telemetry-sender.test.ts b/src/server/services/__tests__/telemetry-sender.test.ts index a432de55..4504a294 100644 --- a/src/server/services/__tests__/telemetry-sender.test.ts +++ b/src/server/services/__tests__/telemetry-sender.test.ts @@ -128,7 +128,7 @@ describe("sendTelemetryHeartbeat — happy path", () => { expect(body.auth_method).toBe("oidc"); }); - it("falls back to 'unknown' for missing env vars", async () => { + it("falls back to 'unknown' for vf_version and 'bare' for deployment_mode when no env vars are set", async () => { vi.unstubAllEnvs(); prismaMock.systemSettings.findUnique.mockResolvedValueOnce(enabledSettings as never); mockCounts(0, 0, 0, 0); @@ -141,9 +141,44 @@ describe("sendTelemetryHeartbeat — happy path", () => { const init = (globalThis.fetch as ReturnType).mock.calls[0][1]; const body = JSON.parse(init.body as string); expect(body.vf_version).toBe("unknown"); + // deployment_mode is auto-detected. In the test env there is no + // KUBERNETES_SERVICE_HOST and no /.dockerenv, so we expect the "bare" + // fallback. ("unknown" is now reserved for explicitly bad VF_DEPLOYMENT_MODE + // overrides.) + expect(body.deployment_mode).toBe("bare"); + }); + + it("returns 'unknown' when VF_DEPLOYMENT_MODE is set to a non-enum value", async () => { + vi.stubEnv("VF_DEPLOYMENT_MODE", "kubernetes"); + prismaMock.systemSettings.findUnique.mockResolvedValueOnce(enabledSettings as never); + mockCounts(0, 0, 0, 0); + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + new Response(null, { status: 204 }) + ); + + await sendTelemetryHeartbeat(); + + const init = (globalThis.fetch as ReturnType).mock.calls[0][1]; + const body = JSON.parse(init.body as string); expect(body.deployment_mode).toBe("unknown"); }); + it("auto-detects 'helm' when KUBERNETES_SERVICE_HOST is present", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("KUBERNETES_SERVICE_HOST", "10.0.0.1"); + prismaMock.systemSettings.findUnique.mockResolvedValueOnce(enabledSettings as never); + mockCounts(0, 0, 0, 0); + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + new Response(null, { status: 204 }) + ); + + await sendTelemetryHeartbeat(); + + const init = (globalThis.fetch as ReturnType).mock.calls[0][1]; + const body = JSON.parse(init.body as string); + expect(body.deployment_mode).toBe("helm"); + }); + it("no-ops when NEXT_PUBLIC_VF_DEMO_MODE=true regardless of DB telemetryEnabled", async () => { vi.stubEnv("NEXT_PUBLIC_VF_DEMO_MODE", "true"); vi.resetModules(); diff --git a/src/server/services/telemetry-sender.ts b/src/server/services/telemetry-sender.ts index 7e7f39b7..952c5711 100644 --- a/src/server/services/telemetry-sender.ts +++ b/src/server/services/telemetry-sender.ts @@ -1,3 +1,4 @@ +import { existsSync } from "fs"; import * as Sentry from "@sentry/nextjs"; import { prisma } from "@/lib/prisma"; import { isDemoMode } from "@/lib/is-demo-mode"; @@ -14,10 +15,27 @@ export function _resetSenderState() { type DeploymentMode = "docker" | "helm" | "bare" | "unknown"; +// Detect deployment mode in this priority order: +// 1. explicit override via VF_DEPLOYMENT_MODE +// 2. Kubernetes (KUBERNETES_SERVICE_HOST is injected into every pod) → "helm" +// (we can't actually distinguish Helm from raw kubectl from the running +// process, so we report Kubernetes-shaped deployments as "helm") +// 3. Docker (the docker engine creates /.dockerenv inside containers) +// 4. fall back to "bare"; "unknown" is only returned if the override env +// var is set to a non-enum value function resolveDeploymentMode(): DeploymentMode { - const v = process.env.VF_DEPLOYMENT_MODE; - if (v === "docker" || v === "helm" || v === "bare") return v; - return "unknown"; + const override = process.env.VF_DEPLOYMENT_MODE; + if (override) { + if (override === "docker" || override === "helm" || override === "bare") return override; + return "unknown"; + } + if (process.env.KUBERNETES_SERVICE_HOST) return "helm"; + try { + if (existsSync("/.dockerenv")) return "docker"; + } catch { + // Filesystem probe failed (likely sandboxed) — fall through. + } + return "bare"; } // Pipeline model uses isDraft (boolean) and deployedAt (DateTime?) — no status enum. diff --git a/src/trpc/init.ts b/src/trpc/init.ts index 314d0316..d84983b7 100644 --- a/src/trpc/init.ts +++ b/src/trpc/init.ts @@ -249,6 +249,16 @@ export const withTeamAccess = (minRole: Role) => } } + if (!teamId && rawInput?.id) { + const nodeGroup = await prisma.nodeGroup.findUnique({ + where: { id: rawInput.id as string }, + select: { environment: { select: { teamId: true } } }, + }); + if (nodeGroup) { + teamId = nodeGroup.environment.teamId ?? undefined; + } + } + if (!teamId && rawInput?.id) { const notifChannel = await prisma.notificationChannel.findUnique({ where: { id: rawInput.id as string },