Feat: added logs UI

This commit is contained in:
Harshith Mullapudi 2025-07-15 22:01:12 +05:30
parent 5dca80b4de
commit 30e5462e14
40 changed files with 2135 additions and 707 deletions

View File

@ -26,8 +26,8 @@ export const APITable = ({
});
return (
<div className="mt-6">
<Table>
<div className="mt-2">
<Table className="bg-background-3 rounded-md">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>

View File

@ -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<ColumnDef<PersonalAccessToken>> => {
},
cell: ({ row }) => {
return (
<div className="flex items-center gap-1 text-sm">
<div className="flex items-center gap-1 text-xs">
{row.original.obfuscatedToken}
</div>
);
@ -64,7 +65,7 @@ export const useTokensColumns = (): Array<ColumnDef<PersonalAccessToken>> => {
},
cell: ({ row }) => {
return (
<div className="flex min-w-[200px] items-center gap-1">
<div className="flex min-w-[200px] items-center gap-1 text-sm">
{row.original.lastAccessedAt
? format(row.original.lastAccessedAt, "MMM d, yyyy")
: "Never"}
@ -81,7 +82,9 @@ export const useTokensColumns = (): Array<ColumnDef<PersonalAccessToken>> => {
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button variant="ghost">Delete</Button>
<Button variant="ghost">
<Trash size={14} />
</Button>
</DialogTrigger>
<DialogContent className="p-3">
<DialogHeader>

View File

@ -150,7 +150,7 @@ export function ConversationTextarea({
)}
/>
</EditorRoot>
<div className="flex justify-end px-3">
<div className="mb-1 flex justify-end px-3">
<Button
variant="default"
className="gap-1 shadow-none transition-all duration-500 ease-in-out"

View File

@ -128,7 +128,7 @@ export const ConversationNew = ({
}}
/>
</EditorRoot>
<div className="flex justify-end px-3">
<div className="mb-1 flex justify-end px-3">
<Button
variant="default"
className="gap-1 shadow-none transition-all duration-500 ease-in-out"

View File

@ -0,0 +1,29 @@
import {
RiDiscordFill,
RiGithubFill,
RiMailFill,
RiSlackFill,
} from "@remixicon/react";
import { LayoutGrid } from "lucide-react";
export const ICON_MAPPING = {
slack: RiSlackFill,
email: RiMailFill,
discord: RiDiscordFill,
github: RiGithubFill,
gmail: RiMailFill,
// Default icon
integration: LayoutGrid,
};
export type IconType = keyof typeof ICON_MAPPING;
export function getIcon(icon: IconType) {
if (icon in ICON_MAPPING) {
return ICON_MAPPING[icon];
}
return ICON_MAPPING["integration"];
}

View File

@ -0,0 +1,248 @@
import React, { useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { FormButtons } from "~/components/ui/FormButtons";
interface IntegrationAuthDialogProps {
integration: {
id: string;
name: string;
description?: string;
spec: any;
};
children: React.ReactNode;
onOpenChange?: (open: boolean) => void;
}
function parseSpec(spec: any) {
if (!spec) return {};
if (typeof spec === "string") {
try {
return JSON.parse(spec);
} catch {
return {};
}
}
return spec;
}
export function IntegrationAuthDialog({
integration,
children,
onOpenChange,
}: IntegrationAuthDialogProps) {
const [apiKey, setApiKey] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
const apiKeyFetcher = useFetcher();
const oauthFetcher = useFetcher<{ redirectURL: string }>();
const mcpFetcher = useFetcher<{ redirectURL: string }>();
const specData = parseSpec(integration.spec);
const hasApiKey = !!specData?.auth?.api_key;
const hasOAuth2 = !!specData?.auth?.OAuth2;
const hasMCPAuth = !!specData?.mcpAuth;
const handleApiKeyConnect = useCallback(() => {
if (!apiKey.trim()) return;
setIsLoading(true);
apiKeyFetcher.submit(
{
integrationDefinitionId: integration.id,
apiKey,
},
{
method: "post",
action: "/api/v1/integration_account",
encType: "application/json",
},
);
}, [integration.id, apiKey, apiKeyFetcher]);
const handleOAuthConnect = useCallback(() => {
setIsConnecting(true);
oauthFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, oauthFetcher]);
const handleMCPConnect = useCallback(() => {
setIsMCPConnecting(true);
mcpFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
mcp: true,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, mcpFetcher]);
// Watch for fetcher completion
React.useEffect(() => {
if (apiKeyFetcher.state === "idle" && isLoading) {
if (apiKeyFetcher.data !== undefined) {
window.location.reload();
}
}
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
React.useEffect(() => {
if (oauthFetcher.state === "idle" && isConnecting) {
if (oauthFetcher.data?.redirectURL) {
window.location.href = oauthFetcher.data.redirectURL;
} else {
setIsConnecting(false);
}
}
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
React.useEffect(() => {
if (mcpFetcher.state === "idle" && isMCPConnecting) {
if (mcpFetcher.data?.redirectURL) {
window.location.href = mcpFetcher.data.redirectURL;
} else {
setIsMCPConnecting(false);
}
}
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
return (
<Dialog
onOpenChange={(open) => {
if (open) {
setApiKey("");
}
onOpenChange?.(open);
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="p-4 sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect to {integration.name}</DialogTitle>
<DialogDescription>
{integration.description ||
`Connect your ${integration.name} account to enable integration.`}
</DialogDescription>
</DialogHeader>
{/* API Key Authentication */}
{hasApiKey && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
{specData?.auth?.api_key?.label || "API Key"}
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
{specData?.auth?.api_key?.description && (
<p className="text-muted-foreground text-xs">
{specData.auth.api_key.description}
</p>
)}
</div>
<FormButtons
confirmButton={
<Button
type="button"
variant="default"
disabled={isLoading || !apiKey.trim()}
onClick={handleApiKeyConnect}
>
{isLoading || apiKeyFetcher.state === "submitting"
? "Connecting..."
: "Connect"}
</Button>
}
/>
</div>
)}
{/* OAuth Authentication */}
{hasOAuth2 && (
<div className="flex justify-center py-4">
<Button
type="button"
variant="default"
size="lg"
disabled={isConnecting || oauthFetcher.state === "submitting"}
onClick={handleOAuthConnect}
>
{isConnecting || oauthFetcher.state === "submitting"
? "Connecting..."
: `Connect to ${integration.name}`}
</Button>
</div>
)}
{/* MCP Authentication */}
{hasMCPAuth && (
<div className="space-y-4 py-4">
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-2">MCP Authentication</h4>
<p className="text-muted-foreground text-xs mb-4">
This integration requires MCP (Model Context Protocol) authentication.
</p>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
onClick={handleMCPConnect}
>
{isMCPConnecting || mcpFetcher.state === "submitting"
? "Connecting..."
: `Connect via MCP`}
</Button>
</div>
</div>
)}
{/* No authentication method found */}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
<div className="text-muted-foreground py-4 text-center">
This integration doesn't specify an authentication method.
</div>
)}
<DialogFooter className="sm:justify-start">
<div className="text-muted-foreground w-full text-xs">
By connecting, you agree to the {integration.name} terms of service.
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<CardWrapper {...cardProps}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="p-4">
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
<Component size={18} />
</div>
<CardTitle className="text-base">{integration.name}</CardTitle>
<CardDescription className="line-clamp-2 text-xs">
{integration.description || `Connect to ${integration.name}`}
</CardDescription>
</CardHeader>
{isConnected && (
<CardFooter className="p-3">
<div className="flex w-full items-center justify-end">
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
Connected
</span>
</div>
</CardFooter>
)}
</Card>
</CardWrapper>
);
}

View File

@ -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<string>;
showDetail?: boolean;
}
export function IntegrationGrid({
integrations,
activeAccountIds,
showDetail = false,
}: IntegrationGridProps) {
const hasActiveAccount = (integrationDefinitionId: string) =>
activeAccountIds.has(integrationDefinitionId);
if (integrations.length === 0) {
return (
<div className="mt-20 flex flex-col items-center justify-center">
<Search className="text-muted-foreground mb-2 h-12 w-12" />
<h3 className="text-lg font-medium">No integrations found</h3>
</div>
);
}
return (
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{integrations.map((integration) => {
const isConnected = hasActiveAccount(integration.id);
if (showDetail) {
return (
<IntegrationCard
key={integration.id}
integration={integration}
isConnected={isConnected}
showDetail={true}
/>
);
}
return (
<IntegrationAuthDialog
key={integration.id}
integration={integration}
>
<IntegrationCard
integration={integration}
isConnected={isConnected}
/>
</IntegrationAuthDialog>
);
})}
</div>
);
}

View File

@ -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<FilterStep>("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 (
<div className="mb-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Filter className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">Filters:</span>
</div>
{/* Source Filter */}
<Popover open={sourceOpen} onOpenChange={setSourceOpen}>
<Popover
open={popoverOpen}
onOpenChange={(open) => {
setPopoverOpen(open);
if (!open) setStep("main");
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
variant="secondary"
role="combobox"
aria-expanded={sourceOpen}
className="w-[200px] justify-between"
aria-expanded={popoverOpen}
className="justify-between"
>
{selectedSourceName || "Select source..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<ListFilter className="mr-2 h-4 w-4" />
Filter
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search sources..." />
<CommandList>
<CommandEmpty>No sources found.</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
<PopoverPortal>
<PopoverContent className="w-[180px] p-0" align="start">
{step === "main" && (
<div className="flex flex-col gap-1 p-2">
<Button
variant="ghost"
className="justify-start"
onClick={() => setStep("source")}
>
Source
</Button>
<Button
variant="ghost"
className="justify-start"
onClick={() => setStep("status")}
>
Status
</Button>
</div>
)}
{step === "source" && (
<div className="flex flex-col gap-1 p-2">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => {
onSourceChange(undefined);
setSourceOpen(false);
setPopoverOpen(false);
setStep("main");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedSource ? "opacity-100" : "opacity-0",
)}
/>
All sources
</CommandItem>
{availableSources.map((source) => (
<CommandItem
</Button>
{limitedSources.map((source) => (
<Button
key={source.slug}
value={source.slug}
onSelect={() => {
variant="ghost"
className="w-full justify-start"
onClick={() => {
onSourceChange(
source.slug === selectedSource
? undefined
: source.slug,
);
setSourceOpen(false);
setPopoverOpen(false);
setStep("main");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedSource === source.slug
? "opacity-100"
: "opacity-0",
)}
/>
{source.name}
</CommandItem>
</Button>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* Status Filter */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={statusOpen}
className="w-[200px] justify-between"
>
{selectedStatusLabel || "Select status..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
{step === "status" && (
<div className="flex flex-col gap-1 p-2">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => {
onStatusChange(undefined);
setStatusOpen(false);
setPopoverOpen(false);
setStep("main");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedStatus ? "opacity-100" : "opacity-0",
)}
/>
All statuses
</CommandItem>
</Button>
{statusOptions.map((status) => (
<CommandItem
<Button
key={status.value}
value={status.value}
onSelect={() => {
variant="ghost"
className="w-full justify-start"
onClick={() => {
onStatusChange(
status.value === selectedStatus
? undefined
: status.value,
);
setStatusOpen(false);
setPopoverOpen(false);
setStep("main");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedStatus === status.value
? "opacity-100"
: "opacity-0",
)}
/>
{status.label}
</CommandItem>
</Button>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</div>
)}
</PopoverContent>
</PopoverPortal>
</Popover>
{/* Active Filters */}
{hasFilters && (
<div className="flex items-center gap-2">
{selectedSource && (
<Badge variant="secondary" className="gap-1">
<Badge variant="secondary" className="h-7 gap-1 rounded">
{selectedSourceName}
<X
className="hover:text-destructive h-3 w-3 cursor-pointer"
className="hover:text-destructive h-4 w-4 cursor-pointer"
onClick={() => onSourceChange(undefined)}
/>
</Badge>
)}
{selectedStatus && (
<Badge variant="secondary" className="gap-1">
<Badge variant="secondary" className="h-7 gap-1 rounded">
{selectedStatusLabel}
<X
className="hover:text-destructive h-3 w-3 cursor-pointer"
className="hover:text-destructive h-4 w-4 cursor-pointer"
onClick={() => onStatusChange(undefined)}
/>
</Badge>
)}
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-6 px-2 text-xs"
>
Clear all
</Button>
</div>
)}
</div>

View File

@ -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 (
<div key={key} style={style} className="p-4">
<div className="h-24 animate-pulse rounded bg-gray-200" />
</div>
<CellMeasurer
key={key}
cache={cache}
columnIndex={0}
parent={parent}
rowIndex={index}
>
<div key={key} style={style} className="p-4">
<div className="h-24 animate-pulse rounded bg-gray-200" />
</div>
</CellMeasurer>
);
}
@ -69,63 +83,69 @@ function LogItemRenderer(props: LogItemRendererProps, logs: LogItem[]) {
};
return (
<div key={key} style={style} className="p-2">
<Card className="h-full">
<CardContent className="p-4">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{log.source}
</Badge>
<div className="flex items-center gap-1">
{getStatusIcon(log.status)}
<Badge className={cn("text-xs", getStatusColor(log.status))}>
{log.status.toLowerCase()}
<CellMeasurer
key={key}
cache={cache}
columnIndex={0}
parent={parent}
rowIndex={index}
>
<div key={key} style={style} className="pb-2">
<Card className="h-full">
<CardContent className="p-4">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{log.source}
</Badge>
<div className="flex items-center gap-1">
{getStatusIcon(log.status)}
<Badge className={cn("text-xs", getStatusColor(log.status))}>
{log.status.toLowerCase()}
</Badge>
</div>
</div>
<div className="text-muted-foreground text-xs">
{new Date(log.time).toLocaleString()}
</div>
</div>
<div className="text-muted-foreground text-xs">
{new Date(log.time).toLocaleString()}
</div>
</div>
<div className="mb-2">
<p className="line-clamp-2 text-sm text-gray-700">
{log.ingestText}
</p>
</div>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
{log.sourceURL && (
<a
href={log.sourceURL}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
Source URL
</a>
)}
{log.processedAt && (
<span>
Processed: {new Date(log.processedAt).toLocaleString()}
</span>
)}
<div className="mb-2">
<p className="text-sm text-gray-700">{log.ingestText}</p>
</div>
{log.error && (
<div className="flex items-center gap-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span className="max-w-[200px] truncate" title={log.error}>
{log.error}
</span>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
{log.sourceURL && (
<a
href={log.sourceURL}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
Source URL
</a>
)}
{log.processedAt && (
<span>
Processed: {new Date(log.processedAt).toLocaleString()}
</span>
)}
</div>
)}
</div>
</CardContent>
</Card>
</div>
{log.error && (
<div className="flex items-center gap-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span className="max-w-[200px] truncate" title={log.error}>
{log.error}
</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</CellMeasurer>
);
}
@ -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<CellMeasurerCache | null>(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 (
<div className="overflow-hidden rounded-lg border">
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={itemCount}
threshold={5}
>
{({ onRowsRendered, registerChild }) => (
<List
ref={registerChild}
height={containerHeight}
<div className="h-[calc(100vh_-_132px)] overflow-hidden rounded-lg">
<AutoSizer className="h-full">
{({ width, height: autoHeight }) => (
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={itemCount}
rowHeight={ITEM_HEIGHT}
onRowsRendered={onRowsRendered}
rowRenderer={rowRenderer}
className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100"
/>
threshold={5}
>
{({ onRowsRendered, registerChild }) => (
<ScrollManagedList
ref={registerChild}
className="h-auto overflow-auto"
height={autoHeight}
width={width}
rowCount={itemCount}
rowHeight={rowHeight}
onRowsRendered={onRowsRendered}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
overscanRowCount={10}
/>
)}
</InfiniteLoader>
)}
</InfiniteLoader>
</AutoSizer>
{isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm">

View File

@ -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 (
<div className="flex flex-col gap-6 p-3 w-3xl">
<div className="flex justify-between">
<div className="shrink-0 flex flex-col">
<h3 className="text-lg"> {title} </h3>
<p className="text-muted-foreground">{description}</p>
{metadata ? metadata : null}
</div>
<div>{actions}</div>
</div>
<div className="grow">
<div className="flex h-full justify-center w-full">
<div className="grow flex flex-col gap-2 h-full">{children}</div>
</div>
</div>
</div>
);
}

View File

@ -29,7 +29,7 @@ const data = {
},
{
title: "Logs",
url: "/home/logs/all",
url: "/home/logs",
icon: Activity,
},
{

View File

@ -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 && <item.icon size={16} />}

View File

@ -11,10 +11,7 @@ export function FormButtons({
}) {
return (
<div
className={cn(
"border-grid-bright flex w-full items-center justify-between border-t pt-4",
className,
)}
className={cn("flex w-full items-center justify-between pt-4", className)}
>
{cancelButton ? cancelButton : <div />} {confirmButton}
</div>

View File

@ -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<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'checkbox peer h-4 w-4 shrink-0 rounded-sm border-1 border-border-dark focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center text-white justify-center')}
>
<CheckIcon className="h-3 w-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -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<string, string> = {
"/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 (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
<div className="flex items-center gap-1">
<SidebarTrigger className="-ml-1" />
<h1 className="text-base">{title}</h1>
{showLogsTabs && (
<div className="ml-2 flex items-center gap-0.5">
<Button
size="sm"
variant="secondary"
className="rounded"
isActive={isAllLogs(location.pathname)}
onClick={() => handleTabClick("all")}
aria-current={logsTab === "all" ? "page" : undefined}
>
All
</Button>
<Button
size="sm"
className="rounded"
onClick={() => handleTabClick("activity")}
isActive={isActivityLogs(location.pathname)}
variant="secondary"
aria-current={logsTab === "activity" ? "page" : undefined}
>
Activity
</Button>
</div>
)}
</div>
<div>
{showNewConversationButton && (
@ -58,7 +115,12 @@ export function SiteHeader() {
)}
{showRequestIntegrationButton && (
<Button
onClick={() => window.open("https://github.com/redplanethq/core/issues/new", "_blank")}
onClick={() =>
window.open(
"https://github.com/redplanethq/core/issues/new",
"_blank",
)
}
variant="secondary"
className="gap-2"
>

View File

@ -0,0 +1,24 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import React from "react";
import { cn } from "../../lib/utils";
const labelVariants = cva(
"font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1 @@
export * from './scroll-managed-list';

View File

@ -0,0 +1,43 @@
import React from "react";
import { List, type ListProps } from "react-virtualized";
interface ScrollManagedListProps extends ListProps {
listId: string;
onScroll: ({ scrollTop }: { scrollTop: number }) => void;
}
export const ScrollManagedList = React.forwardRef<List, ScrollManagedListProps>(
({ listId, ...listProps }) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [scrollTop, setScrollTop] = React.useState(
sessionStorage.getItem(`list-${listId}-scroll`),
);
const handleScroll = React.useCallback(
({ scrollTop }: { scrollTop: number }) => {
setScrollTop(scrollTop.toString());
sessionStorage.setItem(`list-${listId}-scroll`, scrollTop.toString());
if (listProps.onScroll) {
listProps.onScroll({ scrollTop });
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[listId, listProps.onScroll],
);
return (
<List
{...listProps}
height={listProps.height}
rowHeight={listProps.rowHeight}
rowCount={listProps.rowCount}
width={listProps.width}
rowRenderer={listProps.rowRenderer}
scrollTop={parseInt(scrollTop as string, 10)}
onScroll={handleScroll}
/>
);
},
);
ScrollManagedList.displayName = "ScrollManagedList";

View File

@ -17,26 +17,61 @@ export interface IngestionStatusResponse {
export function useIngestionStatus() {
const fetcher = useFetcher<IngestionStatusResponse>();
const [isPolling, setIsPolling] = useState(false);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
const hasActiveRecords = (data: IngestionStatusResponse | undefined) => {
if (!data || !data.queue) return false;
return data.queue.some(item => item.status === "PROCESSING" || item.status === "PENDING");
};
const startPolling = () => {
if (intervalId) return; // Already polling
const pollIngestionStatus = () => {
if (fetcher.state === "idle") {
fetcher.load("/api/v1/ingestion-queue/status");
}
};
// Initial load
pollIngestionStatus();
// Set up polling interval
const interval = setInterval(pollIngestionStatus, 3000); // Poll every 3 seconds
setIntervalId(interval);
setIsPolling(true);
};
return () => {
clearInterval(interval);
const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);
setIntervalId(null);
setIsPolling(false);
}
};
useEffect(() => {
// Initial load to check if we need to start polling
if (fetcher.state === "idle" && !fetcher.data) {
fetcher.load("/api/v1/ingestion-queue/status");
}
}, []);
useEffect(() => {
if (fetcher.data) {
const activeRecords = hasActiveRecords(fetcher.data);
if (activeRecords && !isPolling) {
// Start polling if we have active records and aren't already polling
startPolling();
} else if (!activeRecords && isPolling) {
// Stop polling if no active records and we're currently polling
stopPolling();
}
}
}, [fetcher.data, isPolling]);
useEffect(() => {
return () => {
stopPolling();
};
}, []); // Remove fetcher from dependencies to prevent infinite loop
}, []);
return {
data: fetcher.data,

View File

@ -34,20 +34,25 @@ export function useLogs({ endpoint, source, status }: UseLogsOptions) {
const [logs, setLogs] = useState<LogItem[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [availableSources, setAvailableSources] = useState<Array<{ name: string; slug: string }>>([]);
const [availableSources, setAvailableSources] = useState<
Array<{ name: string; slug: string }>
>([]);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const buildUrl = useCallback((pageNum: number) => {
const params = new URLSearchParams();
params.set('page', pageNum.toString());
params.set('limit', '20');
if (source) params.set('source', source);
if (status) params.set('status', status);
return `${endpoint}?${params.toString()}`;
}, [endpoint, source, status]);
const buildUrl = useCallback(
(pageNum: number) => {
const params = new URLSearchParams();
params.set("page", pageNum.toString());
params.set("limit", "5");
if (source) params.set("source", source);
if (status) params.set("status", status);
return `${endpoint}?${params.toString()}`;
},
[endpoint, source, status],
);
const loadMore = useCallback(() => {
if (fetcher.state === 'idle' && hasMore) {
if (fetcher.state === "idle" && hasMore) {
fetcher.load(buildUrl(page + 1));
}
}, [hasMore, page, buildUrl]);
@ -63,17 +68,22 @@ export function useLogs({ endpoint, source, status }: UseLogsOptions) {
// Effect to handle fetcher data
useEffect(() => {
if (fetcher.data) {
const { logs: newLogs, hasMore: newHasMore, page: currentPage, availableSources: sources } = fetcher.data;
const {
logs: newLogs,
hasMore: newHasMore,
page: currentPage,
availableSources: sources,
} = fetcher.data;
if (currentPage === 1) {
// First page or reset
setLogs(newLogs);
setIsInitialLoad(false);
} else {
// Append to existing logs
setLogs(prev => [...prev, ...newLogs]);
setLogs((prev) => [...prev, ...newLogs]);
}
setHasMore(newHasMore);
setPage(currentPage);
setAvailableSources(sources);
@ -102,7 +112,7 @@ export function useLogs({ endpoint, source, status }: UseLogsOptions) {
loadMore,
reset,
availableSources,
isLoading: fetcher.state === 'loading',
isLoading: fetcher.state === "loading",
isInitialLoad,
};
}

View File

@ -5,6 +5,7 @@ import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server
import { addToQueue } from "~/lib/ingest.server";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.service";
import { triggerWebhookDelivery } from "~/trigger/webhooks/webhook-delivery";
const ActivityCreateSchema = z.object({
text: z.string().min(1, "Text is required"),
@ -74,6 +75,20 @@ const { action, loader } = createActionApiRoute(
queueId: queueResponse.id,
});
// Trigger webhook delivery for the new activity
if (user.Workspace?.id) {
try {
await triggerWebhookDelivery(activity.id, user.Workspace.id);
logger.log("Webhook delivery triggered for activity", { activityId: activity.id });
} catch (webhookError) {
logger.error("Failed to trigger webhook delivery", {
activityId: activity.id,
error: webhookError
});
// Don't fail the entire request if webhook delivery fails
}
}
return json({
success: true,
activity: {

View File

@ -1,11 +1,11 @@
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { prisma } from "~/db.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 = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "20");
const source = url.searchParams.get("source");
@ -103,19 +103,22 @@ export async function loader({ request }: LoaderFunctionArgs) {
// Format the response
const formattedLogs = logs.map((log) => ({
id: log.id,
source: log.activity?.integrationAccount?.integrationDefinition?.name ||
(log.data as any)?.source ||
'Unknown',
ingestText: log.activity?.text ||
(log.data as any)?.episodeBody ||
(log.data as any)?.text ||
'No content',
source:
log.activity?.integrationAccount?.integrationDefinition?.name ||
(log.data as any)?.source ||
"Unknown",
ingestText:
log.activity?.text ||
(log.data as any)?.episodeBody ||
(log.data as any)?.text ||
"No content",
time: log.createdAt,
processedAt: log.processedAt,
status: log.status,
error: log.error,
sourceURL: log.activity?.sourceURL,
integrationSlug: log.activity?.integrationAccount?.integrationDefinition?.slug,
integrationSlug:
log.activity?.integrationAccount?.integrationDefinition?.slug,
activityId: log.activityId,
}));

View File

@ -67,6 +67,8 @@ const { action, loader } = createActionApiRoute(
timeout: 30000,
debug: true,
transportStrategy: transportStrategy || "sse-first",
// Fix this
redirectUrl: "",
},
// Callback to load credentials from the database
async () => {

View File

@ -1,5 +1,5 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { OAuthBodySchema } from "~/services/oauth/oauth-utils.server";
import {
@ -9,7 +9,7 @@ import {
import { getWorkspaceByUser } from "~/models/workspace.server";
// This route handles the OAuth redirect URL generation, similar to the NestJS controller
const { action, loader } = createActionApiRoute(
const { action, loader } = createHybridActionApiRoute(
{
body: OAuthBodySchema,
allowJWT: true,

View File

@ -0,0 +1,30 @@
import { json } from "@remix-run/node";
import { z } from "zod";
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const TestSchema = z.object({
message: z.string(),
});
// This route can be called with either:
// 1. Personal access token: Authorization: Bearer <token>
// 2. Cookie-based authentication (when logged in via browser)
const { action, loader } = createHybridActionApiRoute(
{
body: TestSchema,
method: "POST",
corsStrategy: "all",
},
async ({ body, authentication }) => {
return json({
success: true,
message: body.message,
authType: authentication.type,
userId: authentication.userId,
// Only include scopes if it's API key authentication
...(authentication.type === "PRIVATE" && { scopes: authentication.scopes }),
});
},
);
export { action, loader };

View File

@ -0,0 +1,48 @@
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { requireWorkpace } from "~/services/session.server";
import { redirect, json } from "@remix-run/node";
import { prisma } from "~/db.server";
export async function action({ request, params }: ActionFunctionArgs) {
const workspace = await requireWorkpace(request);
const webhookId = params.id;
if (!webhookId) {
return json({ error: "Webhook ID is required" }, { status: 400 });
}
// Verify webhook belongs to the workspace
const webhook = await prisma.webhookConfiguration.findFirst({
where: {
id: webhookId,
workspaceId: workspace.id,
},
});
if (!webhook) {
return json({ error: "Webhook not found" }, { status: 404 });
}
if (request.method === "POST") {
const formData = await request.formData();
const method = formData.get("_method") as string;
if (method === "DELETE") {
try {
await prisma.webhookConfiguration.delete({
where: {
id: webhookId,
},
});
return redirect("/settings/webhooks");
} catch (error) {
console.error("Error deleting webhook:", error);
return json({ error: "Failed to delete webhook" }, { status: 500 });
}
}
}
return json({ error: "Method not allowed" }, { status: 405 });
}

View File

@ -0,0 +1,47 @@
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { redirect, json } from "@remix-run/node";
import { prisma } from "~/db.server";
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const workspace = await requireWorkpace(request);
if (request.method === "POST") {
const formData = await request.formData();
const url = formData.get("url") as string;
const secret = formData.get("secret") as string;
if (!url) {
return json({ error: "Missing required fields" }, { status: 400 });
}
try {
// Validate URL format
new URL(url);
} catch (error) {
return json({ error: "Invalid URL format" }, { status: 400 });
}
try {
await prisma.webhookConfiguration.create({
data: {
url,
secret: secret || null,
eventTypes: ["activity.created"], // Default to activity events
workspaceId: workspace.id,
userId,
isActive: true,
},
});
return redirect("/settings/webhooks");
} catch (error) {
console.error("Error creating webhook:", error);
return json({ error: "Failed to create webhook" }, { status: 500 });
}
}
return json({ error: "Method not allowed" }, { status: 405 });
}

View File

@ -0,0 +1,181 @@
import React, { useMemo } from "react";
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
import { IntegrationAuthDialog } from "~/components/integrations/IntegrationAuthDialog";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { getIcon, type IconType } from "~/components/icon-utils";
import { ArrowLeft, ExternalLink } from "lucide-react";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const workspace = await requireWorkpace(request);
const { slug } = params;
const [integrationDefinitions, integrationAccounts] = await Promise.all([
getIntegrationDefinitions(workspace.id),
getIntegrationAccounts(userId),
]);
const integration = integrationDefinitions.find(
(def) => def.slug === slug || def.id === slug
);
if (!integration) {
throw new Response("Integration not found", { status: 404 });
}
return json({
integration,
integrationAccounts,
userId,
});
}
function parseSpec(spec: any) {
if (!spec) return {};
if (typeof spec === "string") {
try {
return JSON.parse(spec);
} catch {
return {};
}
}
return spec;
}
export default function IntegrationDetail() {
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
const activeAccount = useMemo(
() =>
integrationAccounts.find(
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive
),
[integrationAccounts, integration.id]
);
const specData = useMemo(() => parseSpec(integration.spec), [integration.spec]);
const hasApiKey = !!specData?.auth?.api_key;
const hasOAuth2 = !!specData?.auth?.OAuth2;
const hasMCPAuth = !!specData?.mcpAuth;
const Component = getIcon(integration.icon as IconType);
return (
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<Link
to="/home/integrations"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={16} />
Back to Integrations
</Link>
</div>
{/* Integration Details */}
<div className="mx-auto max-w-2xl space-y-6">
<Card>
<CardHeader>
<div className="flex items-start gap-4">
<div className="bg-background-2 flex h-12 w-12 items-center justify-center rounded">
<Component size={24} />
</div>
<div className="flex-1">
<CardTitle className="text-2xl">{integration.name}</CardTitle>
<CardDescription className="mt-2 text-base">
{integration.description || `Connect to ${integration.name}`}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Connection Status */}
<div className="mb-6 flex items-center gap-3">
<span className="text-sm font-medium">Status:</span>
{activeAccount ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-sm text-green-800">
Connected
</span>
) : (
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700">
Not Connected
</span>
)}
</div>
{/* Authentication Methods */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Authentication Methods</h3>
<div className="space-y-2">
{hasApiKey && (
<div className="flex items-center gap-2">
<span className="text-sm"> API Key authentication</span>
</div>
)}
{hasOAuth2 && (
<div className="flex items-center gap-2">
<span className="text-sm"> OAuth 2.0 authentication</span>
</div>
)}
{hasMCPAuth && (
<div className="flex items-center gap-2">
<span className="text-sm"> MCP (Model Context Protocol) authentication</span>
</div>
)}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
<div className="text-muted-foreground text-sm">
No authentication method specified
</div>
)}
</div>
</div>
{/* Connect Button */}
{!activeAccount && (hasApiKey || hasOAuth2 || hasMCPAuth) && (
<div className="mt-6 flex justify-center">
<IntegrationAuthDialog integration={integration}>
<Button size="lg" className="px-8">
Connect to {integration.name}
</Button>
</IntegrationAuthDialog>
</div>
)}
{/* Connected Account Info */}
{activeAccount && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">Connected Account</h3>
<div className="rounded-lg border bg-green-50 p-4">
<div className="text-sm text-green-800">
<p className="font-medium">Account ID: {activeAccount.id}</p>
<p className="text-muted-foreground">
Connected on {new Date(activeAccount.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
)}
{/* Integration Spec Details */}
{specData && Object.keys(specData).length > 0 && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">Integration Details</h3>
<div className="rounded-lg border bg-gray-50 p-4">
<pre className="text-sm text-gray-700">
{JSON.stringify(specData, null, 2)}
</pre>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -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<typeof loader>();
const [selectedIntegration, setSelectedIntegration] = useState<any>(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 (
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
@ -133,185 +43,10 @@ export default function Integrations() {
<p className="text-muted-foreground">Connect your tools and services</p>
</div>
{/* Integration cards grid */}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{integrationDefinitions.map((integration) => {
const isConnected = hasActiveAccount(integration.id);
return (
<Dialog
key={integration.id}
onOpenChange={(open) => {
if (open) {
setSelectedIntegration(integration);
setApiKey("");
} else {
setSelectedIntegration(null);
}
}}
>
<DialogTrigger asChild>
<Card className="cursor-pointer transition-all hover:shadow-md">
<CardHeader className="p-4">
<div className="bg-background-2 mb-2 flex h-10 w-10 items-center justify-center rounded">
{integration.icon ? (
<img
src={integration.icon}
alt={integration.name}
className="h-6 w-6"
/>
) : (
<div className="h-6 w-6 rounded-full bg-gray-300" />
)}
</div>
<CardTitle className="text-base">
{integration.name}
</CardTitle>
<CardDescription className="line-clamp-2 text-xs">
{integration.description ||
"Connect to " + integration.name}
</CardDescription>
</CardHeader>
<CardFooter className="border-t p-3">
<div className="flex w-full items-center justify-end">
{isConnected ? (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
Connected
</span>
) : (
<span className="text-muted-foreground text-xs">
Not connected
</span>
)}
</div>
</CardFooter>
</Card>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect to {integration.name}</DialogTitle>
<DialogDescription>
{integration.description ||
`Connect your ${integration.name} account to enable integration.`}
</DialogDescription>
</DialogHeader>
{/* API Key Authentication */}
{(() => {
const specData =
typeof integration.spec === "string"
? JSON.parse(integration.spec)
: integration.spec;
return specData?.auth?.api_key;
})() && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
{(() => {
const specData =
typeof integration.spec === "string"
? JSON.parse(integration.spec)
: integration.spec;
return specData?.auth?.api_key?.label || "API Key";
})()}
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
{(() => {
const specData =
typeof integration.spec === "string"
? JSON.parse(integration.spec)
: integration.spec;
return specData?.auth?.api_key?.description;
})() && (
<p className="text-muted-foreground text-xs">
{(() => {
const specData =
typeof integration.spec === "string"
? JSON.parse(integration.spec)
: integration.spec;
return specData?.auth?.api_key?.description;
})()}
</p>
)}
</div>
<FormButtons
confirmButton={
<Button
type="button"
variant="default"
disabled={isLoading || !apiKey.trim()}
onClick={handleApiKeyConnect}
>
{isLoading ? "Connecting..." : "Connect"}
</Button>
}
></FormButtons>
</div>
)}
{/* OAuth Authentication */}
{(() => {
const specData =
typeof integration.spec === "string"
? JSON.parse(integration.spec)
: integration.spec;
return specData?.auth?.oauth2;
})() && (
<div className="flex justify-center py-8">
<Button
type="button"
variant="default"
size="lg"
disabled={isConnecting}
onClick={handleOAuthConnect}
>
{isConnecting
? "Connecting..."
: `Connect to ${integration.name}`}
</Button>
</div>
)}
{/* No authentication method found */}
{(() => {
const specData =
typeof integration.spec === "string"
? JSON.parse(integration.spec)
: integration.spec;
return !specData?.auth?.api_key && !specData?.auth?.oauth2;
})() && (
<div className="text-muted-foreground py-4 text-center">
This integration doesn't specify an authentication method.
</div>
)}
<DialogFooter className="sm:justify-start">
<div className="text-muted-foreground w-full text-xs">
By connecting, you agree to the {integration.name} terms of
service.
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
})}
</div>
{/* Empty state */}
{integrationDefinitions.length === 0 && (
<div className="mt-20 flex flex-col items-center justify-center">
<Search className="text-muted-foreground mb-2 h-12 w-12" />
<h3 className="text-lg font-medium">No integrations found</h3>
</div>
)}
<IntegrationGrid
integrations={integrationDefinitions}
activeAccountIds={activeAccountIds}
/>
</div>
);
}

View File

@ -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 (
<div className="space-y-6 p-4 px-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<p className="text-muted-foreground">
View all ingestion queue items and their processing status
</p>
</div>
</div>
</div>
{/* Filters */}
<LogsFilters
availableSources={availableSources}
@ -64,18 +48,9 @@ export default function LogsAll() {
{/* Logs List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Ingestion Queue</h2>
{hasMore && (
<span className="text-muted-foreground text-sm">
Scroll to load more...
</span>
)}
</div>
{logs.length === 0 ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<CardContent className="bg-background-2 flex items-center justify-center py-16">
<div className="text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No logs found</h3>

View File

@ -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 (
<div className="home flex h-full flex-col overflow-y-auto p-3">
<div className="flex items-center justify-between">
<div className="space-y-1 text-base">
<h2 className="text-lg font-semibold">API Keys</h2>
<p className="text-muted-foreground">
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.
</p>
</div>
<div>
<Dialog open={open} onOpenChange={setOpen}>
<div className="mx-auto flex w-3xl flex-col gap-4 px-4 py-6">
<Dialog open={open} onOpenChange={setOpen}>
<SettingSection
title="API Keys"
actions={
<DialogTrigger asChild>
<Button
className="inline-flex items-center justify-center gap-1"
@ -118,67 +111,73 @@ export default function API() {
Create
</Button>
</DialogTrigger>
<DialogContent className="p-3">
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
<fetcher.Form
method="post"
onSubmit={onSubmit}
className="space-y-4"
}
description="Create and manage API keys to access your data programmatically."
>
<div className="home flex h-full flex-col overflow-y-auto">
<div className="flex items-center justify-between">
<DialogContent className="p-3">
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
<fetcher.Form
method="post"
onSubmit={onSubmit}
className="space-y-4"
>
<div>
<Input
id="name"
onChange={(e) => setName(e.target.value)}
name="name"
placeholder="Enter API key name"
className="mt-1"
required
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="secondary"
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create API Key"}
</Button>
</div>
</fetcher.Form>
</DialogContent>
</div>
<APITable personalAccessTokens={personalAccessTokens} />
</div>
</SettingSection>
</Dialog>
<Dialog open={showToken} onOpenChange={setShowToken}>
<DialogContent className="p-3">
<DialogHeader>
<DialogTitle>Your New API Key</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
Make sure to copy your API key now. You won't be able to see it
again!
</p>
<div className="flex items-center gap-2 rounded-md border p-3">
<code className="flex-1 text-sm break-all">
{fetcher.data?.token}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(fetcher.data?.token)}
>
<div>
<Input
id="name"
onChange={(e) => setName(e.target.value)}
name="name"
placeholder="Enter API key name"
className="mt-1"
required
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="secondary"
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create API Key"}
</Button>
</div>
</fetcher.Form>
</DialogContent>
</Dialog>
<Dialog open={showToken} onOpenChange={setShowToken}>
<DialogContent className="p-3">
<DialogHeader>
<DialogTitle>Your New API Key</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
Make sure to copy your API key now. You won't be able to see
it again!
</p>
<div className="flex items-center gap-2 rounded-md border p-3">
<code className="flex-1 text-sm break-all">
{fetcher.data?.token}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(fetcher.data?.token)}
>
<Copy size={16} />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<APITable personalAccessTokens={personalAccessTokens} />
<Copy size={16} />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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<typeof loader>();
return (
<div className="home flex h-full flex-col overflow-y-auto p-3">
<div className="flex items-center justify-between">
<div className="space-y-1 text-base">
<h2 className="text-lg font-semibold">Logs</h2>
<p className="text-muted-foreground">
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.
</p>
</div>
</div>
<IngestionLogsTable ingestionLogs={ingestionLogs} />
<div className="mt-4">
{Array.from({ length: pagination.pages }, (_, i) => (
<Link
key={i + 1}
to={`?page=${i + 1}`}
className={`mx-1 rounded border px-2 py-1 ${
pagination.currentPage === i + 1
? "bg-gray-200 font-bold"
: "bg-white"
}`}
>
{i + 1}
</Link>
))}
</div>
</div>
);
}

View File

@ -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();

View File

@ -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<typeof loader>();
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 (
<div className="mx-auto flex w-3xl flex-col gap-4 px-4 py-6">
<Dialog open={isDialogOpen} onOpenChange={handleDialogClose}>
<SettingSection
title="Logs"
actions={
<>
{webhooks.length > 0 && (
<DialogTrigger asChild>
<Button variant="secondary">
<Plus className="mr-2 h-4 w-4" />
Add Webhook
</Button>
</DialogTrigger>
)}
</>
}
description="View and monitor your data ingestion logs."
>
<div className="space-y-2">
{webhooks.length === 0 ? (
<Card>
<CardContent className="bg-background-2 flex flex-col items-center justify-center py-12">
<Globe className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="text-lg font-medium">
No webhooks configured
</h3>
<p className="text-muted-foreground mb-4 text-center">
Add your first webhook to start receiving real-time
notifications
</p>
<Button
onClick={() => setIsDialogOpen(true)}
variant="secondary"
>
<Plus className="mr-2 h-4 w-4" />
Add Webhook
</Button>
</CardContent>
</Card>
) : (
webhooks.map((webhook) => (
<Card key={webhook.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Webhook className="h-4 w-4" />
{webhook.url}
</CardTitle>
<CardDescription className="text-sm">
Created{" "}
{new Date(webhook.createdAt).toLocaleDateString()}
{webhook._count.WebhookDeliveryLog > 0 && (
<span className="ml-2">
{webhook._count.WebhookDeliveryLog} deliveries
</span>
)}
</CardDescription>
</div>
<Form
method="post"
action={`/api/v1/webhooks/${webhook.id}`}
>
<input type="hidden" name="_method" value="DELETE" />
<Button type="submit" variant="ghost" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</Form>
</div>
</CardHeader>
</Card>
))
)}
</div>
</SettingSection>
<DialogContent className="p-4 sm:max-w-md">
<Form method="post" action="/api/v1/webhooks">
<DialogHeader>
<DialogTitle>Add New Webhook</DialogTitle>
<DialogDescription>
Configure a new webhook endpoint to receive activity
notifications.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="url">Webhook URL</Label>
<Input
id="url"
name="url"
type="url"
placeholder="https://your-site.com/webhook"
value={formData.url}
onChange={(e) =>
setFormData((prev) => ({ ...prev, url: e.target.value }))
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="secret">Secret (Optional)</Label>
<Input
id="secret"
name="secret"
placeholder="Your webhook secret"
value={formData.secret}
onChange={(e) =>
setFormData((prev) => ({
...prev,
secret: e.target.value,
}))
}
/>
<p className="text-muted-foreground text-xs">
Used to verify webhook authenticity via HMAC signature
</p>
</div>
</div>
<DialogFooter>
<FormButtons
cancelButton={
<Button
type="button"
variant="ghost"
onClick={() => handleDialogClose(false)}
disabled={isSubmitting}
>
Cancel
</Button>
}
confirmButton={
<Button
type="submit"
variant="secondary"
disabled={isSubmitting || !formData.url}
>
{isSubmitting ? "Adding..." : "Add Webhook"}
</Button>
}
></FormButtons>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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<HybridAuthenticationResult | null> {
// 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<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
body: TBodySchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TBodySchema>
: undefined;
authentication: HybridAuthenticationResult;
request: Request;
}) => Promise<Response>;
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 };
}

View File

@ -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<string, string> = {
"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,
);
}
}

View File

@ -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)

View File

@ -14,6 +14,7 @@
},
"mcpAuth": {
"serverUrl": "https://mcp.linear.app/sse",
"transportStrategy": "sse-first"
"transportStrategy": "sse-first",
"needsSeparateAuth": true
}
}

View File

@ -266,7 +266,6 @@ export class MCPAuthenticationClient {
constructor(private config: MCPRemoteClientConfig) {
this.serverUrlHash = getServerUrlHash(config.serverUrl);
console.log(config);
// Validate configuration
this.validateConfig();
}