diff --git a/apps/webapp/app/components/api/api-table.tsx b/apps/webapp/app/components/api/api-table.tsx index 8ca0c86..f32d06c 100644 --- a/apps/webapp/app/components/api/api-table.tsx +++ b/apps/webapp/app/components/api/api-table.tsx @@ -26,8 +26,8 @@ export const APITable = ({ }); return ( -
- +
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/apps/webapp/app/components/api/columns.tsx b/apps/webapp/app/components/api/columns.tsx index c4b8c7f..f7f3a39 100644 --- a/apps/webapp/app/components/api/columns.tsx +++ b/apps/webapp/app/components/api/columns.tsx @@ -13,6 +13,7 @@ import { DialogTrigger, } from "~/components/ui/dialog"; import React from "react"; +import { Trash } from "lucide-react"; export interface PersonalAccessToken { name: string; @@ -51,7 +52,7 @@ export const useTokensColumns = (): Array> => { }, cell: ({ row }) => { return ( -
+
{row.original.obfuscatedToken}
); @@ -64,7 +65,7 @@ export const useTokensColumns = (): Array> => { }, cell: ({ row }) => { return ( -
+
{row.original.lastAccessedAt ? format(row.original.lastAccessedAt, "MMM d, yyyy") : "Never"} @@ -81,7 +82,9 @@ export const useTokensColumns = (): Array> => { return ( - + diff --git a/apps/webapp/app/components/conversation/conversation-textarea.client.tsx b/apps/webapp/app/components/conversation/conversation-textarea.client.tsx index 7b5747c..cbc5431 100644 --- a/apps/webapp/app/components/conversation/conversation-textarea.client.tsx +++ b/apps/webapp/app/components/conversation/conversation-textarea.client.tsx @@ -150,7 +150,7 @@ export function ConversationTextarea({ )} /> -
+
+ } + /> +
+ )} + + {/* OAuth Authentication */} + {hasOAuth2 && ( +
+ +
+ )} + + {/* MCP Authentication */} + {hasMCPAuth && ( +
+
+

MCP Authentication

+

+ This integration requires MCP (Model Context Protocol) authentication. +

+ +
+
+ )} + + {/* No authentication method found */} + {!hasApiKey && !hasOAuth2 && !hasMCPAuth && ( +
+ This integration doesn't specify an authentication method. +
+ )} + + +
+ By connecting, you agree to the {integration.name} terms of service. +
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/integrations/IntegrationCard.tsx b/apps/webapp/app/components/integrations/IntegrationCard.tsx new file mode 100644 index 0000000..46a8b3b --- /dev/null +++ b/apps/webapp/app/components/integrations/IntegrationCard.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Link } from "@remix-run/react"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { getIcon, type IconType } from "~/components/icon-utils"; + +interface IntegrationCardProps { + integration: { + id: string; + name: string; + description?: string; + icon: string; + slug?: string; + }; + isConnected: boolean; + onClick?: () => void; + showDetail?: boolean; +} + +export function IntegrationCard({ + integration, + isConnected, + onClick, + showDetail = false, +}: IntegrationCardProps) { + const Component = getIcon(integration.icon as IconType); + + const CardWrapper = showDetail ? Link : "div"; + const cardProps = showDetail + ? { to: `/home/integration/${integration.slug || integration.id}` } + : { onClick, className: "cursor-pointer" }; + + return ( + + + +
+ +
+ {integration.name} + + {integration.description || `Connect to ${integration.name}`} + +
+ {isConnected && ( + +
+ + Connected + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/integrations/IntegrationGrid.tsx b/apps/webapp/app/components/integrations/IntegrationGrid.tsx new file mode 100644 index 0000000..2330ad4 --- /dev/null +++ b/apps/webapp/app/components/integrations/IntegrationGrid.tsx @@ -0,0 +1,66 @@ +import React, { useMemo } from "react"; +import { Search } from "lucide-react"; +import { IntegrationCard } from "./IntegrationCard"; +import { IntegrationAuthDialog } from "./IntegrationAuthDialog"; + +interface IntegrationGridProps { + integrations: Array<{ + id: string; + name: string; + description?: string; + icon: string; + slug?: string; + spec: any; + }>; + activeAccountIds: Set; + showDetail?: boolean; +} + +export function IntegrationGrid({ + integrations, + activeAccountIds, + showDetail = false, +}: IntegrationGridProps) { + const hasActiveAccount = (integrationDefinitionId: string) => + activeAccountIds.has(integrationDefinitionId); + + if (integrations.length === 0) { + return ( +
+ +

No integrations found

+
+ ); + } + + return ( +
+ {integrations.map((integration) => { + const isConnected = hasActiveAccount(integration.id); + + if (showDetail) { + return ( + + ); + } + + return ( + + + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/logs/logs-filters.tsx b/apps/webapp/app/components/logs/logs-filters.tsx index 0263a09..5930e44 100644 --- a/apps/webapp/app/components/logs/logs-filters.tsx +++ b/apps/webapp/app/components/logs/logs-filters.tsx @@ -1,21 +1,19 @@ import { useState } from "react"; -import { Check, ChevronsUpDown, Filter, X } from "lucide-react"; -import { Button } from "~/components/ui/button"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "~/components/ui/command"; + ChevronsUpDown, + Filter, + FilterIcon, + ListFilter, + X, +} from "lucide-react"; +import { Button } from "~/components/ui/button"; import { Popover, PopoverContent, + PopoverPortal, PopoverTrigger, } from "~/components/ui/popover"; import { Badge } from "~/components/ui/badge"; -import { cn } from "~/lib/utils"; interface LogsFiltersProps { availableSources: Array<{ name: string; slug: string }>; @@ -29,10 +27,10 @@ const statusOptions = [ { value: "PENDING", label: "Pending" }, { value: "PROCESSING", label: "Processing" }, { value: "COMPLETED", label: "Completed" }, - { value: "FAILED", label: "Failed" }, - { value: "CANCELLED", label: "Cancelled" }, ]; +type FilterStep = "main" | "source" | "status"; + export function LogsFilters({ availableSources, selectedSource, @@ -40,8 +38,11 @@ export function LogsFilters({ onSourceChange, onStatusChange, }: LogsFiltersProps) { - const [sourceOpen, setSourceOpen] = useState(false); - const [statusOpen, setStatusOpen] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false); + const [step, setStep] = useState("main"); + + // Only show first two sources, or "All sources" if none + const limitedSources = availableSources.slice(0, 2); const selectedSourceName = availableSources.find( (s) => s.slug === selectedSource, @@ -50,177 +51,144 @@ export function LogsFilters({ (s) => s.value === selectedStatus, )?.label; - const clearFilters = () => { - onSourceChange(undefined); - onStatusChange(undefined); - }; - const hasFilters = selectedSource || selectedStatus; + // Helper for going back to main step + const handleBack = () => setStep("main"); + return (
-
- - Filters: -
- - {/* Source Filter */} - + { + setPopoverOpen(open); + if (!open) setStep("main"); + }} + > - - - - - No sources found. - - { + + + {step === "main" && ( +
+ + +
+ )} + + {step === "source" && ( +
+ ))} - - - - - +
+ )} - {/* Status Filter */} - - - - - - - - - No status found. - - { + {step === "status" && ( +
+ {statusOptions.map((status) => ( - { + variant="ghost" + className="w-full justify-start" + onClick={() => { onStatusChange( status.value === selectedStatus ? undefined : status.value, ); - setStatusOpen(false); + setPopoverOpen(false); + setStep("main"); }} > - {status.label} - + ))} - - - - +
+ )} +
+
{/* Active Filters */} {hasFilters && (
{selectedSource && ( - + {selectedSourceName} onSourceChange(undefined)} /> )} {selectedStatus && ( - + {selectedStatusLabel} onStatusChange(undefined)} /> )} -
)}
diff --git a/apps/webapp/app/components/logs/virtual-logs-list.tsx b/apps/webapp/app/components/logs/virtual-logs-list.tsx index 2878281..54d9fd4 100644 --- a/apps/webapp/app/components/logs/virtual-logs-list.tsx +++ b/apps/webapp/app/components/logs/virtual-logs-list.tsx @@ -1,10 +1,20 @@ import { useEffect, useRef, useState } from "react"; -import { List, InfiniteLoader, WindowScroller } from "react-virtualized"; -import { LogItem } from "~/hooks/use-logs"; +import { + List, + InfiniteLoader, + WindowScroller, + AutoSizer, + CellMeasurer, + CellMeasurerCache, + type Index, + type ListRowProps, +} from "react-virtualized"; +import { type LogItem } from "~/hooks/use-logs"; import { Badge } from "~/components/ui/badge"; import { Card, CardContent } from "~/components/ui/card"; import { AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react"; import { cn } from "~/lib/utils"; +import { ScrollManagedList } from "../virtualized-list"; interface VirtualLogsListProps { logs: LogItem[]; @@ -14,23 +24,27 @@ interface VirtualLogsListProps { height?: number; } -const ITEM_HEIGHT = 120; - -interface LogItemRendererProps { - index: number; - key: string; - style: React.CSSProperties; -} - -function LogItemRenderer(props: LogItemRendererProps, logs: LogItem[]) { - const { index, key, style } = props; +function LogItemRenderer( + props: ListRowProps, + logs: LogItem[], + cache: CellMeasurerCache, +) { + const { index, key, style, parent } = props; const log = logs[index]; if (!log) { return ( -
-
-
+ +
+
+
+ ); } @@ -69,63 +83,69 @@ function LogItemRenderer(props: LogItemRendererProps, logs: LogItem[]) { }; return ( -
- - -
-
- - {log.source} - -
- {getStatusIcon(log.status)} - - {log.status.toLowerCase()} + +
+ + +
+
+ + {log.source} +
+ {getStatusIcon(log.status)} + + {log.status.toLowerCase()} + +
+
+
+ {new Date(log.time).toLocaleString()}
-
- {new Date(log.time).toLocaleString()} -
-
-
-

- {log.ingestText} -

-
- -
-
- {log.sourceURL && ( - - Source URL - - )} - {log.processedAt && ( - - Processed: {new Date(log.processedAt).toLocaleString()} - - )} +
+

{log.ingestText}

- {log.error && ( -
- - - {log.error} - +
+
+ {log.sourceURL && ( + + Source URL + + )} + {log.processedAt && ( + + Processed: {new Date(log.processedAt).toLocaleString()} + + )}
- )} -
- - -
+ + {log.error && ( +
+ + + {log.error} + +
+ )} +
+ + +
+
); } @@ -136,18 +156,19 @@ export function VirtualLogsList({ isLoading, height = 600, }: VirtualLogsListProps) { - const [containerHeight, setContainerHeight] = useState(height); + // Create a CellMeasurerCache instance using useRef to prevent recreation + const cacheRef = useRef(null); + if (!cacheRef.current) { + cacheRef.current = new CellMeasurerCache({ + defaultHeight: 120, // Default row height + fixedWidth: true, // Rows have fixed width but dynamic height + }); + } + const cache = cacheRef.current; useEffect(() => { - const updateHeight = () => { - const availableHeight = window.innerHeight - 300; // Account for header, filters, etc. - setContainerHeight(Math.min(availableHeight, height)); - }; - - updateHeight(); - window.addEventListener("resize", updateHeight); - return () => window.removeEventListener("resize", updateHeight); - }, [height]); + cache.clearAll(); + }, [logs, cache]); const isRowLoaded = ({ index }: { index: number }) => { return !!logs[index]; @@ -161,32 +182,43 @@ export function VirtualLogsList({ return false; }; - const rowRenderer = (props: LogItemRendererProps) => { - return LogItemRenderer(props, logs); + const rowRenderer = (props: ListRowProps) => { + return LogItemRenderer(props, logs, cache); + }; + + const rowHeight = ({ index }: Index) => { + return cache.getHeight(index, 0); }; const itemCount = hasMore ? logs.length + 1 : logs.length; return ( -
- - {({ onRowsRendered, registerChild }) => ( - + + {({ width, height: autoHeight }) => ( + + threshold={5} + > + {({ onRowsRendered, registerChild }) => ( + + )} + )} - + {isLoading && (
diff --git a/apps/webapp/app/components/setting-section.tsx b/apps/webapp/app/components/setting-section.tsx new file mode 100644 index 0000000..fefa149 --- /dev/null +++ b/apps/webapp/app/components/setting-section.tsx @@ -0,0 +1,34 @@ +interface SettingSectionProps { + title: React.ReactNode | string; + description: React.ReactNode | string; + metadata?: React.ReactNode; + actions?: React.ReactNode; + children: React.ReactNode; +} + +export function SettingSection({ + title, + description, + metadata, + children, + actions, +}: SettingSectionProps) { + return ( +
+
+
+

{title}

+

{description}

+ {metadata ? metadata : null} +
+ +
{actions}
+
+
+
+
{children}
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/sidebar/app-sidebar.tsx b/apps/webapp/app/components/sidebar/app-sidebar.tsx index 426ae5e..67bd5a7 100644 --- a/apps/webapp/app/components/sidebar/app-sidebar.tsx +++ b/apps/webapp/app/components/sidebar/app-sidebar.tsx @@ -29,7 +29,7 @@ const data = { }, { title: "Logs", - url: "/home/logs/all", + url: "/home/logs", icon: Activity, }, { diff --git a/apps/webapp/app/components/sidebar/nav-main.tsx b/apps/webapp/app/components/sidebar/nav-main.tsx index 8ad573a..8a13db6 100644 --- a/apps/webapp/app/components/sidebar/nav-main.tsx +++ b/apps/webapp/app/components/sidebar/nav-main.tsx @@ -34,7 +34,11 @@ export const NavMain = ({ location.pathname.includes(item.url) && "!bg-accent !text-accent-foreground", )} - onClick={() => navigate(item.url)} + onClick={() => + navigate( + item.url.includes("/logs") ? `${item.url}/all` : item.url, + ) + } variant="ghost" > {item.icon && } diff --git a/apps/webapp/app/components/ui/FormButtons.tsx b/apps/webapp/app/components/ui/FormButtons.tsx index 40486f7..09d82d9 100644 --- a/apps/webapp/app/components/ui/FormButtons.tsx +++ b/apps/webapp/app/components/ui/FormButtons.tsx @@ -11,10 +11,7 @@ export function FormButtons({ }) { return (
{cancelButton ? cancelButton :
} {confirmButton}
diff --git a/apps/webapp/app/components/ui/checkbox.tsx b/apps/webapp/app/components/ui/checkbox.tsx new file mode 100644 index 0000000..9145ade --- /dev/null +++ b/apps/webapp/app/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from '@radix-ui/react-icons'; +import React from 'react'; + +import { cn } from '../../lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/apps/webapp/app/components/ui/header.tsx b/apps/webapp/app/components/ui/header.tsx index b991f86..c102adc 100644 --- a/apps/webapp/app/components/ui/header.tsx +++ b/apps/webapp/app/components/ui/header.tsx @@ -2,6 +2,7 @@ import { useLocation, useNavigate } from "@remix-run/react"; import { Button } from "./button"; import { Plus } from "lucide-react"; import { SidebarTrigger } from "./sidebar"; +import React from "react"; const PAGE_TITLES: Record = { "/home/dashboard": "Memory graph", @@ -30,6 +31,25 @@ function isIntegrationsPage(pathname: string): boolean { return pathname === "/home/integrations"; } +function isAllLogs(pathname: string): boolean { + return pathname === "/home/logs/all"; +} + +function isActivityLogs(pathname: string): boolean { + return pathname === "/home/logs/activity"; +} + +function isLogsPage(pathname: string): boolean { + // Matches /home/logs, /home/logs/all, /home/logs/activity, or any /home/logs/* + return pathname.includes("/home/logs"); +} + +function getLogsTab(pathname: string): "all" | "activity" { + if (pathname.startsWith("/home/logs/activity")) return "activity"; + // Default to "all" for /home/logs or /home/logs/all or anything else + return "all"; +} + export function SiteHeader() { const location = useLocation(); const navigate = useNavigate(); @@ -37,13 +57,50 @@ export function SiteHeader() { const showNewConversationButton = isConversationDetail(location.pathname); const showRequestIntegrationButton = isIntegrationsPage(location.pathname); + const showLogsTabs = isLogsPage(location.pathname); + + const logsTab = getLogsTab(location.pathname); + + const handleTabClick = (tab: "all" | "activity") => { + if (tab === "all") { + navigate("/home/logs/all"); + } else if (tab === "activity") { + navigate("/home/logs/activity"); + } + }; return (
+

{title}

+ + {showLogsTabs && ( +
+ + +
+ )}
{showNewConversationButton && ( @@ -58,7 +115,12 @@ export function SiteHeader() { )} {showRequestIntegrationButton && ( + +
+ )} + + {/* Connected Account Info */} + {activeAccount && ( +
+

Connected Account

+
+
+

Account ID: {activeAccount.id}

+

+ Connected on {new Date(activeAccount.createdAt).toLocaleDateString()} +

+
+
+
+ )} + + {/* Integration Spec Details */} + {specData && Object.keys(specData).length > 0 && ( +
+

Integration Details

+
+
+                    {JSON.stringify(specData, null, 2)}
+                  
+
+
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/home.integrations.tsx b/apps/webapp/app/routes/home.integrations.tsx index 335b098..1096280 100644 --- a/apps/webapp/app/routes/home.integrations.tsx +++ b/apps/webapp/app/routes/home.integrations.tsx @@ -1,34 +1,12 @@ -import { useState } from "react"; +import React, { useMemo } from "react"; import { json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { requireUserId, requireWorkpace } from "~/services/session.server"; import { getIntegrationDefinitions } from "~/services/integrationDefinition.server"; import { getIntegrationAccounts } from "~/services/integrationAccount.server"; +import { IntegrationGrid } from "~/components/integrations/IntegrationGrid"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { Input } from "~/components/ui/input"; -import { FormButtons } from "~/components/ui/FormButtons"; -import { Plus, Search } from "lucide-react"; - -// Loader to fetch integration definitions and existing accounts export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const workspace = await requireWorkpace(request); @@ -46,86 +24,18 @@ export async function loader({ request }: LoaderFunctionArgs) { } export default function Integrations() { - const { integrationDefinitions, integrationAccounts, userId } = + const { integrationDefinitions, integrationAccounts } = useLoaderData(); - const [selectedIntegration, setSelectedIntegration] = useState(null); - const [apiKey, setApiKey] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - // Check if user has an active account for an integration - const hasActiveAccount = (integrationDefinitionId: string) => { - return integrationAccounts.some( - (account) => - account.integrationDefinitionId === integrationDefinitionId && - account.isActive, - ); - }; - - // Handle connection with API key - const handleApiKeyConnect = async () => { - if (!selectedIntegration || !apiKey.trim()) return; - - setIsLoading(true); - try { - const response = await fetch("/api/v1/integration_account", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - integrationDefinitionId: selectedIntegration.id, - apiKey, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to connect integration"); - } - - // Refresh the page to show the new integration account - window.location.reload(); - } catch (error) { - console.error("Error connecting integration:", error); - // Handle error (could add error state and display message) - } finally { - setIsLoading(false); - } - }; - - // Handle OAuth connection - const handleOAuthConnect = async () => { - if (!selectedIntegration) return; - - setIsConnecting(true); - try { - const response = await fetch("/api/v1/oauth", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - integrationDefinitionId: selectedIntegration.id, - userId, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to start OAuth flow"); - } - - const { url } = await response.json(); - // Redirect to OAuth authorization URL - window.location.href = url; - } catch (error) { - console.error("Error starting OAuth flow:", error); - // Handle error - } finally { - setIsConnecting(false); - } - }; + const activeAccountIds = useMemo( + () => + new Set( + integrationAccounts + .filter((acc) => acc.isActive) + .map((acc) => acc.integrationDefinitionId), + ), + [integrationAccounts], + ); return (
@@ -133,185 +43,10 @@ export default function Integrations() {

Connect your tools and services

- {/* Integration cards grid */} -
- {integrationDefinitions.map((integration) => { - const isConnected = hasActiveAccount(integration.id); - - return ( - { - if (open) { - setSelectedIntegration(integration); - setApiKey(""); - } else { - setSelectedIntegration(null); - } - }} - > - - - -
- {integration.icon ? ( - {integration.name} - ) : ( -
- )} -
- - {integration.name} - - - {integration.description || - "Connect to " + integration.name} - - - -
- {isConnected ? ( - - Connected - - ) : ( - - Not connected - - )} -
-
- - - - - - Connect to {integration.name} - - {integration.description || - `Connect your ${integration.name} account to enable integration.`} - - - - {/* API Key Authentication */} - {(() => { - const specData = - typeof integration.spec === "string" - ? JSON.parse(integration.spec) - : integration.spec; - return specData?.auth?.api_key; - })() && ( -
-
- - setApiKey(e.target.value)} - /> - {(() => { - const specData = - typeof integration.spec === "string" - ? JSON.parse(integration.spec) - : integration.spec; - return specData?.auth?.api_key?.description; - })() && ( -

- {(() => { - const specData = - typeof integration.spec === "string" - ? JSON.parse(integration.spec) - : integration.spec; - return specData?.auth?.api_key?.description; - })()} -

- )} -
- - - {isLoading ? "Connecting..." : "Connect"} - - } - > -
- )} - - {/* OAuth Authentication */} - {(() => { - const specData = - typeof integration.spec === "string" - ? JSON.parse(integration.spec) - : integration.spec; - return specData?.auth?.oauth2; - })() && ( -
- -
- )} - - {/* No authentication method found */} - {(() => { - const specData = - typeof integration.spec === "string" - ? JSON.parse(integration.spec) - : integration.spec; - return !specData?.auth?.api_key && !specData?.auth?.oauth2; - })() && ( -
- This integration doesn't specify an authentication method. -
- )} - - -
- By connecting, you agree to the {integration.name} terms of - service. -
-
-
-
- ); - })} -
- - {/* Empty state */} - {integrationDefinitions.length === 0 && ( -
- -

No integrations found

-
- )} +
); } diff --git a/apps/webapp/app/routes/home.logs.all.tsx b/apps/webapp/app/routes/home.logs.all.tsx index 6d872e1..e817807 100644 --- a/apps/webapp/app/routes/home.logs.all.tsx +++ b/apps/webapp/app/routes/home.logs.all.tsx @@ -2,13 +2,8 @@ import { useState } from "react"; import { useLogs } from "~/hooks/use-logs"; import { LogsFilters } from "~/components/logs/logs-filters"; import { VirtualLogsList } from "~/components/logs/virtual-logs-list"; -import { - AppContainer, - PageContainer, - PageBody, -} from "~/components/layout/app-layout"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Badge } from "~/components/ui/badge"; +import { AppContainer, PageContainer } from "~/components/layout/app-layout"; +import { Card, CardContent } from "~/components/ui/card"; import { Database } from "lucide-react"; export default function LogsAll() { @@ -42,17 +37,6 @@ export default function LogsAll() { return (
- {/* Header */} -
-
-
-

- View all ingestion queue items and their processing status -

-
-
-
- {/* Filters */} -
-

Ingestion Queue

- {hasMore && ( - - Scroll to load more... - - )} -
- {logs.length === 0 ? ( - +

No logs found

diff --git a/apps/webapp/app/routes/settings.api.tsx b/apps/webapp/app/routes/settings.api.tsx index b9960c7..e3b9919 100644 --- a/apps/webapp/app/routes/settings.api.tsx +++ b/apps/webapp/app/routes/settings.api.tsx @@ -25,6 +25,7 @@ import { import { requireUserId } from "~/services/session.server"; import { useTypedLoaderData } from "remix-typedjson"; import { APITable } from "~/components/api"; +import { SettingSection } from "~/components/setting-section"; export const APIKeyBodyRequest = z.object({ name: z.string(), @@ -96,19 +97,11 @@ export default function API() { }; return ( -
-
-
-

API Keys

-

- Create and manage API keys to access your data programmatically. API - keys allow secure access to your workspace's data and functionality - through our REST API. -

-
- -
- +
+ + +
+ + +
+ + +
+ + + + + + + Your New API Key + +
+

+ Make sure to copy your API key now. You won't be able to see it + again! +

+
+ + {fetcher.data?.token} + + -
- - -
- - - - - Your New API Key - -
-

- Make sure to copy your API key now. You won't be able to see - it again! -

-
- - {fetcher.data?.token} - - -
-
-
-
-
-
- - + + +
+
+ +
); } diff --git a/apps/webapp/app/routes/settings.logs.tsx b/apps/webapp/app/routes/settings.logs.tsx deleted file mode 100644 index b5e9399..0000000 --- a/apps/webapp/app/routes/settings.logs.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { json } from "@remix-run/node"; -import { Link, useLoaderData } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { IngestionLogsTable } from "~/components/logs"; -import { getIngestionLogs } from "~/services/ingestionLogs.server"; -import { requireUserId } from "~/services/session.server"; - -export async function loader({ request }: LoaderFunctionArgs) { - const userId = await requireUserId(request); - const url = new URL(request.url); - const page = Number(url.searchParams.get("page") || 1); - - const { ingestionLogs, pagination } = await getIngestionLogs(userId, page); - - return json({ ingestionLogs, pagination }); -} - -export default function Logs() { - const { ingestionLogs, pagination } = useLoaderData(); - - return ( -
-
-
-

Logs

-

- View and monitor your data ingestion logs. These logs show the - history of data being loaded into memory, helping you track and - debug the ingestion process. -

-
-
- - -
- {Array.from({ length: pagination.pages }, (_, i) => ( - - {i + 1} - - ))} -
-
- ); -} diff --git a/apps/webapp/app/routes/settings.tsx b/apps/webapp/app/routes/settings.tsx index 5b1ab2f..d49fc58 100644 --- a/apps/webapp/app/routes/settings.tsx +++ b/apps/webapp/app/routes/settings.tsx @@ -6,6 +6,7 @@ import { Code, User, Workflow, + Webhook, } from "lucide-react"; import React from "react"; @@ -50,9 +51,9 @@ export default function Settings() { const data = { nav: [ - { name: "Workspace", icon: Building }, - { name: "Preferences", icon: User }, + // { name: "Workspace", icon: Building }, { name: "API", icon: Code }, + { name: "Webhooks", icon: Webhook }, ], }; const navigate = useNavigate(); diff --git a/apps/webapp/app/routes/settings.webhooks.tsx b/apps/webapp/app/routes/settings.webhooks.tsx new file mode 100644 index 0000000..64dbce5 --- /dev/null +++ b/apps/webapp/app/routes/settings.webhooks.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect, useRef } from "react"; +import { json } from "@remix-run/node"; +import { useLoaderData, Form, useNavigation } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { requireUserId, requireWorkpace } from "~/services/session.server"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Badge } from "~/components/ui/badge"; +import { FormButtons } from "~/components/ui/FormButtons"; +import { Plus, Trash2, Globe, Check, X, Webhook } from "lucide-react"; +import { prisma } from "~/db.server"; +import { SettingSection } from "~/components/setting-section"; + +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const workspace = await requireWorkpace(request); + + const webhooks = await prisma.webhookConfiguration.findMany({ + where: { + workspaceId: workspace.id, + }, + include: { + _count: { + select: { + WebhookDeliveryLog: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return json({ + webhooks, + workspace, + }); +} + +export default function WebhooksSettings() { + const { webhooks, workspace } = useLoaderData(); + const navigation = useNavigation(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [formData, setFormData] = useState({ + url: "", + secret: "", + }); + + // Track previous submitting state to detect when submission finishes + const prevIsSubmitting = useRef(false); + const isSubmitting = navigation.state === "submitting"; + + // Close dialog when submission finishes and was open + useEffect(() => { + if (prevIsSubmitting.current && !isSubmitting && isDialogOpen) { + setIsDialogOpen(false); + setFormData({ url: "", secret: "" }); + } + prevIsSubmitting.current = isSubmitting; + }, [isSubmitting, isDialogOpen]); + + const resetForm = () => { + setFormData({ + url: "", + secret: "", + }); + }; + + const handleDialogClose = (open: boolean) => { + setIsDialogOpen(open); + if (!open) { + resetForm(); + } + }; + + return ( +
+ + + {webhooks.length > 0 && ( + + + + )} + + } + description="View and monitor your data ingestion logs." + > +
+ {webhooks.length === 0 ? ( + + + +

+ No webhooks configured +

+

+ Add your first webhook to start receiving real-time + notifications +

+ +
+
+ ) : ( + webhooks.map((webhook) => ( + + +
+
+ + + {webhook.url} + + + Created{" "} + {new Date(webhook.createdAt).toLocaleDateString()} + {webhook._count.WebhookDeliveryLog > 0 && ( + + • {webhook._count.WebhookDeliveryLog} deliveries + + )} + +
+
+ + + +
+
+
+ )) + )} +
+
+ + +
+ + Add New Webhook + + Configure a new webhook endpoint to receive activity + notifications. + + + +
+
+ + + setFormData((prev) => ({ ...prev, url: e.target.value })) + } + required + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + secret: e.target.value, + })) + } + /> +

+ Used to verify webhook authenticity via HMAC signature +

+
+
+ + + handleDialogClose(false)} + disabled={isSubmitting} + > + Cancel + + } + confirmButton={ + + } + > + + +
+
+
+ ); +} diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index f922700..c686cf0 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -16,6 +16,7 @@ import { checkAuthorization, } from "../authorization.server"; import { logger } from "../logger.service"; +import { getUserId } from "../session.server"; import { safeJsonParse } from "~/utils/json"; @@ -632,3 +633,326 @@ async function wrapResponse( return response; } + +// New hybrid authentication types and functions +export type HybridAuthenticationResult = ApiAuthenticationResultSuccess | { + ok: true; + type: "COOKIE"; + userId: string; +}; + +async function authenticateHybridRequest( + request: Request, + options: { allowJWT?: boolean } = {}, +): Promise { + // First try API key authentication + const apiResult = await authenticateApiRequestWithFailure(request, options); + if (apiResult.ok) { + return apiResult; + } + + // If API key fails, try cookie authentication + const userId = await getUserId(request); + if (userId) { + return { + ok: true, + type: "COOKIE", + userId, + }; + } + + return null; +} + +type HybridActionRouteBuilderOptions< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +> = { + params?: TParamsSchema; + searchParams?: TSearchParamsSchema; + headers?: THeadersSchema; + allowJWT?: boolean; + corsStrategy?: "all" | "none"; + method?: "POST" | "PUT" | "DELETE" | "PATCH"; + authorization?: { + action: AuthorizationAction; + }; + maxContentLength?: number; + body?: TBodySchema; +}; + +type HybridActionHandlerFunction< + TParamsSchema extends AnyZodSchema | undefined, + TSearchParamsSchema extends AnyZodSchema | undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +> = (args: { + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + headers: THeadersSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + body: TBodySchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + authentication: HybridAuthenticationResult; + request: Request; +}) => Promise; + +export function createHybridActionApiRoute< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +>( + options: HybridActionRouteBuilderOptions< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TBodySchema + >, + handler: HybridActionHandlerFunction< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TBodySchema + >, +) { + const { + params: paramsSchema, + searchParams: searchParamsSchema, + headers: headersSchema, + body: bodySchema, + allowJWT = false, + corsStrategy = "none", + authorization, + maxContentLength, + } = options; + + async function loader({ request, params }: LoaderFunctionArgs) { + if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { + return apiCors(request, json({})); + } + + return new Response(null, { status: 405 }); + } + + async function action({ request, params }: ActionFunctionArgs) { + if (options.method) { + if (request.method.toUpperCase() !== options.method) { + return await wrapResponse( + request, + json( + { error: "Method not allowed" }, + { status: 405, headers: { Allow: options.method } }, + ), + corsStrategy !== "none", + ); + } + } + + try { + const authenticationResult = await authenticateHybridRequest( + request, + { allowJWT }, + ); + + if (!authenticationResult) { + return await wrapResponse( + request, + json({ error: "Authentication required" }, { status: 401 }), + corsStrategy !== "none", + ); + } + + if (maxContentLength) { + const contentLength = request.headers.get("content-length"); + + if (!contentLength || parseInt(contentLength) > maxContentLength) { + return json({ error: "Request body too large" }, { status: 413 }); + } + } + + let parsedParams: any = undefined; + if (paramsSchema) { + const parsed = paramsSchema.safeParse(params); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Params Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedParams = parsed.data; + } + + let parsedSearchParams: any = undefined; + if (searchParamsSchema) { + const searchParams = Object.fromEntries( + new URL(request.url).searchParams, + ); + const parsed = searchParamsSchema.safeParse(searchParams); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Query Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedSearchParams = parsed.data; + } + + let parsedHeaders: any = undefined; + if (headersSchema) { + const rawHeaders = Object.fromEntries(request.headers); + const headers = headersSchema.safeParse(rawHeaders); + if (!headers.success) { + return await wrapResponse( + request, + json( + { + error: "Headers Error", + details: fromZodError(headers.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedHeaders = headers.data; + } + + let parsedBody: any = undefined; + if (bodySchema) { + const rawBody = await request.text(); + if (rawBody.length === 0) { + return await wrapResponse( + request, + json({ error: "Request body is empty" }, { status: 400 }), + corsStrategy !== "none", + ); + } + + const rawParsedJson = safeJsonParse(rawBody); + + if (!rawParsedJson) { + return await wrapResponse( + request, + json({ error: "Invalid JSON" }, { status: 400 }), + corsStrategy !== "none", + ); + } + + const body = bodySchema.safeParse(rawParsedJson); + if (!body.success) { + return await wrapResponse( + request, + json( + { error: fromZodError(body.error).toString() }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedBody = body.data; + } + + // Authorization check - only applies to API key authentication + if (authorization && authenticationResult.type === "PRIVATE") { + const { action } = authorization; + + logger.debug("Checking authorization", { + action, + scopes: authenticationResult.scopes, + }); + + const authorizationResult = checkAuthorization(authenticationResult); + + if (!authorizationResult.authorized) { + return await wrapResponse( + request, + json( + { + error: `Unauthorized: ${authorizationResult.reason}`, + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 }, + ), + corsStrategy !== "none", + ); + } + } + + const result = await handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + body: parsedBody, + authentication: authenticationResult, + request, + }); + return await wrapResponse(request, result, corsStrategy !== "none"); + } catch (error) { + try { + if (error instanceof Response) { + return await wrapResponse(request, error, corsStrategy !== "none"); + } + + logger.error("Error in hybrid action", { + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : String(error), + url: request.url, + }); + + return await wrapResponse( + request, + json({ error: "Internal Server Error" }, { status: 500 }), + corsStrategy !== "none", + ); + } catch (innerError) { + logger.error("[apiBuilder] Failed to handle error", { + error, + innerError, + }); + + return json({ error: "Internal Server Error" }, { status: 500 }); + } + } + } + + return { loader, action }; +} diff --git a/apps/webapp/app/trigger/webhooks/webhook-delivery.ts b/apps/webapp/app/trigger/webhooks/webhook-delivery.ts new file mode 100644 index 0000000..b456e9b --- /dev/null +++ b/apps/webapp/app/trigger/webhooks/webhook-delivery.ts @@ -0,0 +1,224 @@ +import { queue, task } from "@trigger.dev/sdk"; + +import { logger } from "~/services/logger.service"; +import { WebhookDeliveryStatus } from "@core/database"; +import crypto from "crypto"; +import { prisma } from "~/db.server"; + +const webhookQueue = queue({ + name: "webhook-delivery-queue", +}); + +interface WebhookDeliveryPayload { + activityId: string; + workspaceId: string; +} + +export const webhookDeliveryTask = task({ + id: "webhook-delivery", + queue: webhookQueue, + run: async (payload: WebhookDeliveryPayload) => { + try { + logger.log( + `Processing webhook delivery for activity ${payload.activityId}`, + ); + + // Get the activity data + const activity = await prisma.activity.findUnique({ + where: { id: payload.activityId }, + include: { + integrationAccount: { + include: { + integrationDefinition: true, + }, + }, + workspace: true, + }, + }); + + if (!activity) { + logger.error(`Activity ${payload.activityId} not found`); + return { success: false, error: "Activity not found" }; + } + + // Get active webhooks for this workspace + const webhooks = await prisma.webhookConfiguration.findMany({ + where: { + workspaceId: payload.workspaceId, + isActive: true, + }, + }); + + if (webhooks.length === 0) { + logger.log( + `No active webhooks found for workspace ${payload.workspaceId}`, + ); + return { success: true, message: "No webhooks to deliver to" }; + } + + // Prepare webhook payload + const webhookPayload = { + event: "activity.created", + timestamp: new Date().toISOString(), + data: { + id: activity.id, + text: activity.text, + sourceURL: activity.sourceURL, + createdAt: activity.createdAt, + updatedAt: activity.updatedAt, + integrationAccount: activity.integrationAccount + ? { + id: activity.integrationAccount.id, + integrationDefinition: { + name: activity.integrationAccount.integrationDefinition.name, + slug: activity.integrationAccount.integrationDefinition.slug, + }, + } + : null, + workspace: { + id: activity.workspace.id, + name: activity.workspace.name, + }, + }, + }; + + const payloadString = JSON.stringify(webhookPayload); + const deliveryResults = []; + + // Deliver to each webhook + for (const webhook of webhooks) { + const deliveryId = crypto.randomUUID(); + + try { + // Create delivery log entry + const deliveryLog = await prisma.webhookDeliveryLog.create({ + data: { + webhookConfigurationId: webhook.id, + activityId: activity.id, + status: WebhookDeliveryStatus.FAILED, // Will update if successful + }, + }); + + // Prepare headers + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "Echo-Webhooks/1.0", + "X-Webhook-Delivery": deliveryId, + "X-Webhook-Event": "activity.created", + }; + + // Add HMAC signature if secret is configured + if (webhook.secret) { + const signature = crypto + .createHmac("sha256", webhook.secret) + .update(payloadString) + .digest("hex"); + headers["X-Hub-Signature-256"] = `sha256=${signature}`; + } + + // Make the HTTP request + const response = await fetch(webhook.url, { + method: "POST", + headers, + body: payloadString, + signal: AbortSignal.timeout(30000), // 30 second timeout + }); + + const responseBody = await response.text().catch(() => ""); + + // Update delivery log with results + await prisma.webhookDeliveryLog.update({ + where: { id: deliveryLog.id }, + data: { + status: response.ok + ? WebhookDeliveryStatus.SUCCESS + : WebhookDeliveryStatus.FAILED, + responseStatusCode: response.status, + responseBody: responseBody.slice(0, 1000), // Limit response body length + error: response.ok + ? null + : `HTTP ${response.status}: ${response.statusText}`, + }, + }); + + deliveryResults.push({ + webhookId: webhook.id, + success: response.ok, + statusCode: response.status, + error: response.ok + ? null + : `HTTP ${response.status}: ${response.statusText}`, + }); + + logger.log(`Webhook delivery to ${webhook.url}: ${response.status}`); + } catch (error: any) { + // Update delivery log with error + const deliveryLog = await prisma.webhookDeliveryLog.findFirst({ + where: { + webhookConfigurationId: webhook.id, + activityId: activity.id, + }, + orderBy: { createdAt: "desc" }, + }); + + if (deliveryLog) { + await prisma.webhookDeliveryLog.update({ + where: { id: deliveryLog.id }, + data: { + status: WebhookDeliveryStatus.FAILED, + error: error.message, + }, + }); + } + + deliveryResults.push({ + webhookId: webhook.id, + success: false, + error: error.message, + }); + + logger.error(`Error delivering webhook to ${webhook.url}:`, error); + } + } + + const successCount = deliveryResults.filter((r) => r.success).length; + const totalCount = deliveryResults.length; + + logger.log( + `Webhook delivery completed: ${successCount}/${totalCount} successful`, + ); + + return { + success: true, + delivered: successCount, + total: totalCount, + results: deliveryResults, + }; + } catch (error: any) { + logger.error( + `Error in webhook delivery task for activity ${payload.activityId}:`, + error, + ); + return { success: false, error: error.message }; + } + }, +}); + +// Helper function to trigger webhook delivery +export async function triggerWebhookDelivery( + activityId: string, + workspaceId: string, +) { + try { + await webhookDeliveryTask.trigger({ + activityId, + workspaceId, + }); + logger.log(`Triggered webhook delivery for activity ${activityId}`); + } catch (error: any) { + logger.error( + `Failed to trigger webhook delivery for activity ${activityId}:`, + error, + ); + } +} diff --git a/apps/webapp/prisma/schema.prisma b/apps/webapp/prisma/schema.prisma index 996ed8c..621128e 100644 --- a/apps/webapp/prisma/schema.prisma +++ b/apps/webapp/prisma/schema.prisma @@ -31,6 +31,7 @@ model Activity { WebhookDeliveryLog WebhookDeliveryLog[] ConversationHistory ConversationHistory[] + IngestionQueue IngestionQueue[] } model AuthorizationCode { @@ -136,6 +137,9 @@ model IngestionQueue { workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id]) + activity Activity? @relation(fields: [activityId], references: [id]) + activityId String? + // Error handling error String? retryCount Int @default(0) diff --git a/integrations/linear/spec.json b/integrations/linear/spec.json index e614786..9dd6b86 100644 --- a/integrations/linear/spec.json +++ b/integrations/linear/spec.json @@ -14,6 +14,7 @@ }, "mcpAuth": { "serverUrl": "https://mcp.linear.app/sse", - "transportStrategy": "sse-first" + "transportStrategy": "sse-first", + "needsSeparateAuth": true } } \ No newline at end of file diff --git a/packages/mcp-proxy/src/core/mcp-remote-client.ts b/packages/mcp-proxy/src/core/mcp-remote-client.ts index 1677e0d..e525393 100644 --- a/packages/mcp-proxy/src/core/mcp-remote-client.ts +++ b/packages/mcp-proxy/src/core/mcp-remote-client.ts @@ -266,7 +266,6 @@ export class MCPAuthenticationClient { constructor(private config: MCPRemoteClientConfig) { this.serverUrlHash = getServerUrlHash(config.serverUrl); - console.log(config); // Validate configuration this.validateConfig(); }