Feat: added logs UI

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

View File

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

View File

@ -13,6 +13,7 @@ import {
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import React from "react"; import React from "react";
import { Trash } from "lucide-react";
export interface PersonalAccessToken { export interface PersonalAccessToken {
name: string; name: string;
@ -51,7 +52,7 @@ export const useTokensColumns = (): Array<ColumnDef<PersonalAccessToken>> => {
}, },
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="flex items-center gap-1 text-sm"> <div className="flex items-center gap-1 text-xs">
{row.original.obfuscatedToken} {row.original.obfuscatedToken}
</div> </div>
); );
@ -64,7 +65,7 @@ export const useTokensColumns = (): Array<ColumnDef<PersonalAccessToken>> => {
}, },
cell: ({ row }) => { cell: ({ row }) => {
return ( 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 {row.original.lastAccessedAt
? format(row.original.lastAccessedAt, "MMM d, yyyy") ? format(row.original.lastAccessedAt, "MMM d, yyyy")
: "Never"} : "Never"}
@ -81,7 +82,9 @@ export const useTokensColumns = (): Array<ColumnDef<PersonalAccessToken>> => {
return ( return (
<Dialog onOpenChange={setOpen} open={open}> <Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost">Delete</Button> <Button variant="ghost">
<Trash size={14} />
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="p-3"> <DialogContent className="p-3">
<DialogHeader> <DialogHeader>

View File

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

View File

@ -128,7 +128,7 @@ export const ConversationNew = ({
}} }}
/> />
</EditorRoot> </EditorRoot>
<div className="flex justify-end px-3"> <div className="mb-1 flex justify-end px-3">
<Button <Button
variant="default" variant="default"
className="gap-1 shadow-none transition-all duration-500 ease-in-out" 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 { useState } from "react";
import { Check, ChevronsUpDown, Filter, X } from "lucide-react";
import { Button } from "~/components/ui/button";
import { import {
Command, ChevronsUpDown,
CommandEmpty, Filter,
CommandGroup, FilterIcon,
CommandInput, ListFilter,
CommandItem, X,
CommandList, } from "lucide-react";
} from "~/components/ui/command"; import { Button } from "~/components/ui/button";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverPortal,
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
interface LogsFiltersProps { interface LogsFiltersProps {
availableSources: Array<{ name: string; slug: string }>; availableSources: Array<{ name: string; slug: string }>;
@ -29,10 +27,10 @@ const statusOptions = [
{ value: "PENDING", label: "Pending" }, { value: "PENDING", label: "Pending" },
{ value: "PROCESSING", label: "Processing" }, { value: "PROCESSING", label: "Processing" },
{ value: "COMPLETED", label: "Completed" }, { value: "COMPLETED", label: "Completed" },
{ value: "FAILED", label: "Failed" },
{ value: "CANCELLED", label: "Cancelled" },
]; ];
type FilterStep = "main" | "source" | "status";
export function LogsFilters({ export function LogsFilters({
availableSources, availableSources,
selectedSource, selectedSource,
@ -40,8 +38,11 @@ export function LogsFilters({
onSourceChange, onSourceChange,
onStatusChange, onStatusChange,
}: LogsFiltersProps) { }: LogsFiltersProps) {
const [sourceOpen, setSourceOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [statusOpen, setStatusOpen] = 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( const selectedSourceName = availableSources.find(
(s) => s.slug === selectedSource, (s) => s.slug === selectedSource,
@ -50,177 +51,144 @@ export function LogsFilters({
(s) => s.value === selectedStatus, (s) => s.value === selectedStatus,
)?.label; )?.label;
const clearFilters = () => {
onSourceChange(undefined);
onStatusChange(undefined);
};
const hasFilters = selectedSource || selectedStatus; const hasFilters = selectedSource || selectedStatus;
// Helper for going back to main step
const handleBack = () => setStep("main");
return ( return (
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<div className="flex items-center gap-2"> <Popover
<Filter className="text-muted-foreground h-4 w-4" /> open={popoverOpen}
<span className="text-sm font-medium">Filters:</span> onOpenChange={(open) => {
</div> setPopoverOpen(open);
if (!open) setStep("main");
{/* Source Filter */}
<Popover open={sourceOpen} onOpenChange={setSourceOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceOpen}
className="w-[200px] justify-between"
>
{selectedSourceName || "Select source..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search sources..." />
<CommandList>
<CommandEmpty>No sources found.</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
onSourceChange(undefined);
setSourceOpen(false);
}} }}
> >
<Check <PopoverTrigger asChild>
className={cn( <Button
"mr-2 h-4 w-4", variant="secondary"
!selectedSource ? "opacity-100" : "opacity-0", role="combobox"
aria-expanded={popoverOpen}
className="justify-between"
>
<ListFilter className="mr-2 h-4 w-4" />
Filter
</Button>
</PopoverTrigger>
<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);
setPopoverOpen(false);
setStep("main");
}}
>
All sources All sources
</CommandItem> </Button>
{availableSources.map((source) => ( {limitedSources.map((source) => (
<CommandItem <Button
key={source.slug} key={source.slug}
value={source.slug} variant="ghost"
onSelect={() => { className="w-full justify-start"
onClick={() => {
onSourceChange( onSourceChange(
source.slug === selectedSource source.slug === selectedSource
? undefined ? undefined
: source.slug, : 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} {source.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 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> </Button>
</PopoverTrigger> ))}
<PopoverContent className="w-[200px] p-0"> </div>
<Command> )}
<CommandInput placeholder="Search status..." />
<CommandList> {step === "status" && (
<CommandEmpty>No status found.</CommandEmpty> <div className="flex flex-col gap-1 p-2">
<CommandGroup> <Button
<CommandItem variant="ghost"
value="" className="w-full justify-start"
onSelect={() => { onClick={() => {
onStatusChange(undefined); onStatusChange(undefined);
setStatusOpen(false); setPopoverOpen(false);
setStep("main");
}} }}
> >
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedStatus ? "opacity-100" : "opacity-0",
)}
/>
All statuses All statuses
</CommandItem> </Button>
{statusOptions.map((status) => ( {statusOptions.map((status) => (
<CommandItem <Button
key={status.value} key={status.value}
value={status.value} variant="ghost"
onSelect={() => { className="w-full justify-start"
onClick={() => {
onStatusChange( onStatusChange(
status.value === selectedStatus status.value === selectedStatus
? undefined ? undefined
: status.value, : 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} {status.label}
</CommandItem> </Button>
))} ))}
</CommandGroup> </div>
</CommandList> )}
</Command>
</PopoverContent> </PopoverContent>
</PopoverPortal>
</Popover> </Popover>
{/* Active Filters */} {/* Active Filters */}
{hasFilters && ( {hasFilters && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedSource && ( {selectedSource && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="h-7 gap-1 rounded">
{selectedSourceName} {selectedSourceName}
<X <X
className="hover:text-destructive h-3 w-3 cursor-pointer" className="hover:text-destructive h-4 w-4 cursor-pointer"
onClick={() => onSourceChange(undefined)} onClick={() => onSourceChange(undefined)}
/> />
</Badge> </Badge>
)} )}
{selectedStatus && ( {selectedStatus && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="h-7 gap-1 rounded">
{selectedStatusLabel} {selectedStatusLabel}
<X <X
className="hover:text-destructive h-3 w-3 cursor-pointer" className="hover:text-destructive h-4 w-4 cursor-pointer"
onClick={() => onStatusChange(undefined)} onClick={() => onStatusChange(undefined)}
/> />
</Badge> </Badge>
)} )}
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-6 px-2 text-xs"
>
Clear all
</Button>
</div> </div>
)} )}
</div> </div>

View File

@ -1,10 +1,20 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { List, InfiniteLoader, WindowScroller } from "react-virtualized"; import {
import { LogItem } from "~/hooks/use-logs"; 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 { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react"; import { AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { ScrollManagedList } from "../virtualized-list";
interface VirtualLogsListProps { interface VirtualLogsListProps {
logs: LogItem[]; logs: LogItem[];
@ -14,23 +24,27 @@ interface VirtualLogsListProps {
height?: number; height?: number;
} }
const ITEM_HEIGHT = 120; function LogItemRenderer(
props: ListRowProps,
interface LogItemRendererProps { logs: LogItem[],
index: number; cache: CellMeasurerCache,
key: string; ) {
style: React.CSSProperties; const { index, key, style, parent } = props;
}
function LogItemRenderer(props: LogItemRendererProps, logs: LogItem[]) {
const { index, key, style } = props;
const log = logs[index]; const log = logs[index];
if (!log) { if (!log) {
return ( return (
<CellMeasurer
key={key}
cache={cache}
columnIndex={0}
parent={parent}
rowIndex={index}
>
<div key={key} style={style} className="p-4"> <div key={key} style={style} className="p-4">
<div className="h-24 animate-pulse rounded bg-gray-200" /> <div className="h-24 animate-pulse rounded bg-gray-200" />
</div> </div>
</CellMeasurer>
); );
} }
@ -69,7 +83,14 @@ function LogItemRenderer(props: LogItemRendererProps, logs: LogItem[]) {
}; };
return ( return (
<div key={key} style={style} className="p-2"> <CellMeasurer
key={key}
cache={cache}
columnIndex={0}
parent={parent}
rowIndex={index}
>
<div key={key} style={style} className="pb-2">
<Card className="h-full"> <Card className="h-full">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="mb-2 flex items-start justify-between"> <div className="mb-2 flex items-start justify-between">
@ -90,9 +111,7 @@ function LogItemRenderer(props: LogItemRendererProps, logs: LogItem[]) {
</div> </div>
<div className="mb-2"> <div className="mb-2">
<p className="line-clamp-2 text-sm text-gray-700"> <p className="text-sm text-gray-700">{log.ingestText}</p>
{log.ingestText}
</p>
</div> </div>
<div className="text-muted-foreground flex items-center justify-between text-xs"> <div className="text-muted-foreground flex items-center justify-between text-xs">
@ -126,6 +145,7 @@ function LogItemRenderer(props: LogItemRendererProps, logs: LogItem[]) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</CellMeasurer>
); );
} }
@ -136,18 +156,19 @@ export function VirtualLogsList({
isLoading, isLoading,
height = 600, height = 600,
}: VirtualLogsListProps) { }: 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(() => { useEffect(() => {
const updateHeight = () => { cache.clearAll();
const availableHeight = window.innerHeight - 300; // Account for header, filters, etc. }, [logs, cache]);
setContainerHeight(Math.min(availableHeight, height));
};
updateHeight();
window.addEventListener("resize", updateHeight);
return () => window.removeEventListener("resize", updateHeight);
}, [height]);
const isRowLoaded = ({ index }: { index: number }) => { const isRowLoaded = ({ index }: { index: number }) => {
return !!logs[index]; return !!logs[index];
@ -161,14 +182,20 @@ export function VirtualLogsList({
return false; return false;
}; };
const rowRenderer = (props: LogItemRendererProps) => { const rowRenderer = (props: ListRowProps) => {
return LogItemRenderer(props, logs); return LogItemRenderer(props, logs, cache);
};
const rowHeight = ({ index }: Index) => {
return cache.getHeight(index, 0);
}; };
const itemCount = hasMore ? logs.length + 1 : logs.length; const itemCount = hasMore ? logs.length + 1 : logs.length;
return ( return (
<div className="overflow-hidden rounded-lg border"> <div className="h-[calc(100vh_-_132px)] overflow-hidden rounded-lg">
<AutoSizer className="h-full">
{({ width, height: autoHeight }) => (
<InfiniteLoader <InfiniteLoader
isRowLoaded={isRowLoaded} isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows} loadMoreRows={loadMoreRows}
@ -176,17 +203,22 @@ export function VirtualLogsList({
threshold={5} threshold={5}
> >
{({ onRowsRendered, registerChild }) => ( {({ onRowsRendered, registerChild }) => (
<List <ScrollManagedList
ref={registerChild} ref={registerChild}
height={containerHeight} className="h-auto overflow-auto"
height={autoHeight}
width={width}
rowCount={itemCount} rowCount={itemCount}
rowHeight={ITEM_HEIGHT} rowHeight={rowHeight}
onRowsRendered={onRowsRendered} onRowsRendered={onRowsRendered}
rowRenderer={rowRenderer} rowRenderer={rowRenderer}
className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100" deferredMeasurementCache={cache}
overscanRowCount={10}
/> />
)} )}
</InfiniteLoader> </InfiniteLoader>
)}
</AutoSizer>
{isLoading && ( {isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm"> <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", title: "Logs",
url: "/home/logs/all", url: "/home/logs",
icon: Activity, icon: Activity,
}, },
{ {

View File

@ -34,7 +34,11 @@ export const NavMain = ({
location.pathname.includes(item.url) && location.pathname.includes(item.url) &&
"!bg-accent !text-accent-foreground", "!bg-accent !text-accent-foreground",
)} )}
onClick={() => navigate(item.url)} onClick={() =>
navigate(
item.url.includes("/logs") ? `${item.url}/all` : item.url,
)
}
variant="ghost" variant="ghost"
> >
{item.icon && <item.icon size={16} />} {item.icon && <item.icon size={16} />}

View File

@ -11,10 +11,7 @@ export function FormButtons({
}) { }) {
return ( return (
<div <div
className={cn( className={cn("flex w-full items-center justify-between pt-4", className)}
"border-grid-bright flex w-full items-center justify-between border-t pt-4",
className,
)}
> >
{cancelButton ? cancelButton : <div />} {confirmButton} {cancelButton ? cancelButton : <div />} {confirmButton}
</div> </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 { Button } from "./button";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { SidebarTrigger } from "./sidebar"; import { SidebarTrigger } from "./sidebar";
import React from "react";
const PAGE_TITLES: Record<string, string> = { const PAGE_TITLES: Record<string, string> = {
"/home/dashboard": "Memory graph", "/home/dashboard": "Memory graph",
@ -30,6 +31,25 @@ function isIntegrationsPage(pathname: string): boolean {
return pathname === "/home/integrations"; 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() { export function SiteHeader() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -37,13 +57,50 @@ export function SiteHeader() {
const showNewConversationButton = isConversationDetail(location.pathname); const showNewConversationButton = isConversationDetail(location.pathname);
const showRequestIntegrationButton = isIntegrationsPage(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 ( 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)"> <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 w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<h1 className="text-base">{title}</h1> <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>
<div> <div>
{showNewConversationButton && ( {showNewConversationButton && (
@ -58,7 +115,12 @@ export function SiteHeader() {
)} )}
{showRequestIntegrationButton && ( {showRequestIntegrationButton && (
<Button <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" variant="secondary"
className="gap-2" 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() { export function useIngestionStatus() {
const fetcher = useFetcher<IngestionStatusResponse>(); const fetcher = useFetcher<IngestionStatusResponse>();
const [isPolling, setIsPolling] = useState(false); const [isPolling, setIsPolling] = useState(false);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
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
useEffect(() => {
const pollIngestionStatus = () => { const pollIngestionStatus = () => {
if (fetcher.state === "idle") { if (fetcher.state === "idle") {
fetcher.load("/api/v1/ingestion-queue/status"); fetcher.load("/api/v1/ingestion-queue/status");
} }
}; };
// Initial load
pollIngestionStatus();
// Set up polling interval
const interval = setInterval(pollIngestionStatus, 3000); // Poll every 3 seconds const interval = setInterval(pollIngestionStatus, 3000); // Poll every 3 seconds
setIntervalId(interval);
setIsPolling(true); setIsPolling(true);
return () => {
clearInterval(interval);
setIsPolling(false);
}; };
}, []); // Remove fetcher from dependencies to prevent infinite loop
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();
};
}, []);
return { return {
data: fetcher.data, data: fetcher.data,

View File

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

View File

@ -5,6 +5,7 @@ import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server
import { addToQueue } from "~/lib/ingest.server"; import { addToQueue } from "~/lib/ingest.server";
import { prisma } from "~/db.server"; import { prisma } from "~/db.server";
import { logger } from "~/services/logger.service"; import { logger } from "~/services/logger.service";
import { triggerWebhookDelivery } from "~/trigger/webhooks/webhook-delivery";
const ActivityCreateSchema = z.object({ const ActivityCreateSchema = z.object({
text: z.string().min(1, "Text is required"), text: z.string().min(1, "Text is required"),
@ -74,6 +75,20 @@ const { action, loader } = createActionApiRoute(
queueId: queueResponse.id, 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({ return json({
success: true, success: true,
activity: { activity: {

View File

@ -1,4 +1,4 @@
import { LoaderFunctionArgs, json } from "@remix-run/node"; import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { prisma } from "~/db.server"; import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server"; import { requireUserId } from "~/services/session.server";
@ -103,19 +103,22 @@ export async function loader({ request }: LoaderFunctionArgs) {
// Format the response // Format the response
const formattedLogs = logs.map((log) => ({ const formattedLogs = logs.map((log) => ({
id: log.id, id: log.id,
source: log.activity?.integrationAccount?.integrationDefinition?.name || source:
log.activity?.integrationAccount?.integrationDefinition?.name ||
(log.data as any)?.source || (log.data as any)?.source ||
'Unknown', "Unknown",
ingestText: log.activity?.text || ingestText:
log.activity?.text ||
(log.data as any)?.episodeBody || (log.data as any)?.episodeBody ||
(log.data as any)?.text || (log.data as any)?.text ||
'No content', "No content",
time: log.createdAt, time: log.createdAt,
processedAt: log.processedAt, processedAt: log.processedAt,
status: log.status, status: log.status,
error: log.error, error: log.error,
sourceURL: log.activity?.sourceURL, sourceURL: log.activity?.sourceURL,
integrationSlug: log.activity?.integrationAccount?.integrationDefinition?.slug, integrationSlug:
log.activity?.integrationAccount?.integrationDefinition?.slug,
activityId: log.activityId, activityId: log.activityId,
})); }));

View File

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

View File

@ -1,5 +1,5 @@
import { json } from "@remix-run/node"; 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 { OAuthBodySchema } from "~/services/oauth/oauth-utils.server";
import { import {
@ -9,7 +9,7 @@ import {
import { getWorkspaceByUser } from "~/models/workspace.server"; import { getWorkspaceByUser } from "~/models/workspace.server";
// This route handles the OAuth redirect URL generation, similar to the NestJS controller // This route handles the OAuth redirect URL generation, similar to the NestJS controller
const { action, loader } = createActionApiRoute( const { action, loader } = createHybridActionApiRoute(
{ {
body: OAuthBodySchema, body: OAuthBodySchema,
allowJWT: true, 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 { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react"; import { useLoaderData } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUserId, requireWorkpace } from "~/services/session.server"; import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server"; import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.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) { export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request); const userId = await requireUserId(request);
const workspace = await requireWorkpace(request); const workspace = await requireWorkpace(request);
@ -46,86 +24,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
} }
export default function Integrations() { export default function Integrations() {
const { integrationDefinitions, integrationAccounts, userId } = const { integrationDefinitions, integrationAccounts } =
useLoaderData<typeof loader>(); 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 activeAccountIds = useMemo(
const hasActiveAccount = (integrationDefinitionId: string) => { () =>
return integrationAccounts.some( new Set(
(account) => integrationAccounts
account.integrationDefinitionId === integrationDefinitionId && .filter((acc) => acc.isActive)
account.isActive, .map((acc) => acc.integrationDefinitionId),
),
[integrationAccounts],
); );
};
// 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);
}
};
return ( return (
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5"> <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> <p className="text-muted-foreground">Connect your tools and services</p>
</div> </div>
{/* Integration cards grid */} <IntegrationGrid
<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={integrationDefinitions}
{integrationDefinitions.map((integration) => { activeAccountIds={activeAccountIds}
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>
)}
</div> </div>
); );
} }

View File

@ -2,13 +2,8 @@ import { useState } from "react";
import { useLogs } from "~/hooks/use-logs"; import { useLogs } from "~/hooks/use-logs";
import { LogsFilters } from "~/components/logs/logs-filters"; import { LogsFilters } from "~/components/logs/logs-filters";
import { VirtualLogsList } from "~/components/logs/virtual-logs-list"; import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
import { import { AppContainer, PageContainer } from "~/components/layout/app-layout";
AppContainer, import { Card, CardContent } from "~/components/ui/card";
PageContainer,
PageBody,
} from "~/components/layout/app-layout";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
export default function LogsAll() { export default function LogsAll() {
@ -42,17 +37,6 @@ export default function LogsAll() {
return ( return (
<div className="space-y-6 p-4 px-5"> <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 */} {/* Filters */}
<LogsFilters <LogsFilters
availableSources={availableSources} availableSources={availableSources}
@ -64,18 +48,9 @@ export default function LogsAll() {
{/* Logs List */} {/* Logs List */}
<div className="space-y-4"> <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 ? ( {logs.length === 0 ? (
<Card> <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"> <div className="text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <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> <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 { requireUserId } from "~/services/session.server";
import { useTypedLoaderData } from "remix-typedjson"; import { useTypedLoaderData } from "remix-typedjson";
import { APITable } from "~/components/api"; import { APITable } from "~/components/api";
import { SettingSection } from "~/components/setting-section";
export const APIKeyBodyRequest = z.object({ export const APIKeyBodyRequest = z.object({
name: z.string(), name: z.string(),
@ -96,19 +97,11 @@ export default function API() {
}; };
return ( return (
<div className="home flex h-full flex-col overflow-y-auto p-3"> <div className="mx-auto flex w-3xl flex-col gap-4 px-4 py-6">
<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}> <Dialog open={open} onOpenChange={setOpen}>
<SettingSection
title="API Keys"
actions={
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
className="inline-flex items-center justify-center gap-1" className="inline-flex items-center justify-center gap-1"
@ -118,6 +111,11 @@ export default function API() {
Create Create
</Button> </Button>
</DialogTrigger> </DialogTrigger>
}
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"> <DialogContent className="p-3">
<DialogHeader> <DialogHeader>
<DialogTitle>Create API Key</DialogTitle> <DialogTitle>Create API Key</DialogTitle>
@ -148,6 +146,11 @@ export default function API() {
</div> </div>
</fetcher.Form> </fetcher.Form>
</DialogContent> </DialogContent>
</div>
<APITable personalAccessTokens={personalAccessTokens} />
</div>
</SettingSection>
</Dialog> </Dialog>
<Dialog open={showToken} onOpenChange={setShowToken}> <Dialog open={showToken} onOpenChange={setShowToken}>
@ -157,8 +160,8 @@ export default function API() {
</DialogHeader> </DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Make sure to copy your API key now. You won't be able to see Make sure to copy your API key now. You won't be able to see it
it again! again!
</p> </p>
<div className="flex items-center gap-2 rounded-md border p-3"> <div className="flex items-center gap-2 rounded-md border p-3">
<code className="flex-1 text-sm break-all"> <code className="flex-1 text-sm break-all">
@ -176,9 +179,5 @@ export default function API() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</div>
<APITable personalAccessTokens={personalAccessTokens} />
</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, Code,
User, User,
Workflow, Workflow,
Webhook,
} from "lucide-react"; } from "lucide-react";
import React from "react"; import React from "react";
@ -50,9 +51,9 @@ export default function Settings() {
const data = { const data = {
nav: [ nav: [
{ name: "Workspace", icon: Building }, // { name: "Workspace", icon: Building },
{ name: "Preferences", icon: User },
{ name: "API", icon: Code }, { name: "API", icon: Code },
{ name: "Webhooks", icon: Webhook },
], ],
}; };
const navigate = useNavigate(); 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, checkAuthorization,
} from "../authorization.server"; } from "../authorization.server";
import { logger } from "../logger.service"; import { logger } from "../logger.service";
import { getUserId } from "../session.server";
import { safeJsonParse } from "~/utils/json"; import { safeJsonParse } from "~/utils/json";
@ -632,3 +633,326 @@ async function wrapResponse(
return response; 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[] WebhookDeliveryLog WebhookDeliveryLog[]
ConversationHistory ConversationHistory[] ConversationHistory ConversationHistory[]
IngestionQueue IngestionQueue[]
} }
model AuthorizationCode { model AuthorizationCode {
@ -136,6 +137,9 @@ model IngestionQueue {
workspaceId String workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id]) workspace Workspace @relation(fields: [workspaceId], references: [id])
activity Activity? @relation(fields: [activityId], references: [id])
activityId String?
// Error handling // Error handling
error String? error String?
retryCount Int @default(0) retryCount Int @default(0)

View File

@ -14,6 +14,7 @@
}, },
"mcpAuth": { "mcpAuth": {
"serverUrl": "https://mcp.linear.app/sse", "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) { constructor(private config: MCPRemoteClientConfig) {
this.serverUrlHash = getServerUrlHash(config.serverUrl); this.serverUrlHash = getServerUrlHash(config.serverUrl);
console.log(config);
// Validate configuration // Validate configuration
this.validateConfig(); this.validateConfig();
} }