mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-12 02:38:29 +00:00
Feat: added logs UI
This commit is contained in:
parent
5dca80b4de
commit
30e5462e14
@ -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}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
29
apps/webapp/app/components/icon-utils.tsx
Normal file
29
apps/webapp/app/components/icon-utils.tsx
Normal 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"];
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
63
apps/webapp/app/components/integrations/IntegrationCard.tsx
Normal file
63
apps/webapp/app/components/integrations/IntegrationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/webapp/app/components/integrations/IntegrationGrid.tsx
Normal file
66
apps/webapp/app/components/integrations/IntegrationGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
34
apps/webapp/app/components/setting-section.tsx
Normal file
34
apps/webapp/app/components/setting-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -29,7 +29,7 @@ const data = {
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "/home/logs/all",
|
||||
url: "/home/logs",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
|
||||
@ -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} />}
|
||||
|
||||
@ -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>
|
||||
|
||||
28
apps/webapp/app/components/ui/checkbox.tsx
Normal file
28
apps/webapp/app/components/ui/checkbox.tsx
Normal 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 };
|
||||
@ -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"
|
||||
>
|
||||
|
||||
24
apps/webapp/app/components/ui/label.tsx
Normal file
24
apps/webapp/app/components/ui/label.tsx
Normal 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 };
|
||||
1
apps/webapp/app/components/virtualized-list/index.ts
Normal file
1
apps/webapp/app/components/virtualized-list/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './scroll-managed-list';
|
||||
@ -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";
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
30
apps/webapp/app/routes/api.v1.test-hybrid.tsx
Normal file
30
apps/webapp/app/routes/api.v1.test-hybrid.tsx
Normal 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 };
|
||||
48
apps/webapp/app/routes/api.v1.webhooks.$id.tsx
Normal file
48
apps/webapp/app/routes/api.v1.webhooks.$id.tsx
Normal 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 });
|
||||
}
|
||||
47
apps/webapp/app/routes/api.v1.webhooks.tsx
Normal file
47
apps/webapp/app/routes/api.v1.webhooks.tsx
Normal 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 });
|
||||
}
|
||||
181
apps/webapp/app/routes/home.integration.$slug.tsx
Normal file
181
apps/webapp/app/routes/home.integration.$slug.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
245
apps/webapp/app/routes/settings.webhooks.tsx
Normal file
245
apps/webapp/app/routes/settings.webhooks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
224
apps/webapp/app/trigger/webhooks/webhook-delivery.ts
Normal file
224
apps/webapp/app/trigger/webhooks/webhook-delivery.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
},
|
||||
"mcpAuth": {
|
||||
"serverUrl": "https://mcp.linear.app/sse",
|
||||
"transportStrategy": "sse-first"
|
||||
"transportStrategy": "sse-first",
|
||||
"needsSeparateAuth": true
|
||||
}
|
||||
}
|
||||
@ -266,7 +266,6 @@ export class MCPAuthenticationClient {
|
||||
constructor(private config: MCPRemoteClientConfig) {
|
||||
this.serverUrlHash = getServerUrlHash(config.serverUrl);
|
||||
|
||||
console.log(config);
|
||||
// Validate configuration
|
||||
this.validateConfig();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user