mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-27 07:38:30 +00:00
Fix: UI for integrations and logs
This commit is contained in:
parent
32379a0b0e
commit
78d401f618
109
apps/webapp/app/components/integrations/api-key-auth-section.tsx
Normal file
109
apps/webapp/app/components/integrations/api-key-auth-section.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
||||||
|
interface ApiKeyAuthSectionProps {
|
||||||
|
integration: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
specData: any;
|
||||||
|
activeAccount: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyAuthSection({
|
||||||
|
integration,
|
||||||
|
specData,
|
||||||
|
activeAccount,
|
||||||
|
}: ApiKeyAuthSectionProps) {
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
|
||||||
|
const apiKeyFetcher = useFetcher();
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (apiKeyFetcher.state === "idle" && isLoading) {
|
||||||
|
if (apiKeyFetcher.data !== undefined) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
|
||||||
|
|
||||||
|
if (activeAccount || !specData?.auth?.api_key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium">API Key Authentication</h4>
|
||||||
|
{!showApiKeyForm ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowApiKeyForm(true)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Connect with API Key
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="apiKey" className="text-sm font-medium">
|
||||||
|
{specData?.auth?.api_key?.label || "API Key"}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="apiKey"
|
||||||
|
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>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowApiKeyForm(false);
|
||||||
|
setApiKey("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
disabled={isLoading || !apiKey.trim()}
|
||||||
|
onClick={handleApiKeyConnect}
|
||||||
|
>
|
||||||
|
{isLoading || apiKeyFetcher.state === "submitting"
|
||||||
|
? "Connecting..."
|
||||||
|
: "Connect"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
|
||||||
|
interface IngestionRuleSectionProps {
|
||||||
|
ingestionRule: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
} | null;
|
||||||
|
activeAccount: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngestionRuleSection({
|
||||||
|
ingestionRule,
|
||||||
|
activeAccount,
|
||||||
|
}: IngestionRuleSectionProps) {
|
||||||
|
const [ingestionRuleText, setIngestionRuleText] = useState(
|
||||||
|
ingestionRule?.text || "",
|
||||||
|
);
|
||||||
|
const ingestionRuleFetcher = useFetcher();
|
||||||
|
|
||||||
|
const handleIngestionRuleUpdate = useCallback(() => {
|
||||||
|
ingestionRuleFetcher.submit(
|
||||||
|
{
|
||||||
|
ingestionRule: ingestionRuleText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "post",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [ingestionRuleText, ingestionRuleFetcher]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (ingestionRuleFetcher.state === "idle") {
|
||||||
|
// Optionally show success message or refresh
|
||||||
|
}
|
||||||
|
}, [ingestionRuleFetcher.state, ingestionRuleFetcher.data]);
|
||||||
|
|
||||||
|
if (!activeAccount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Ingestion Rule</h3>
|
||||||
|
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="ingestionRule" className="text-sm font-medium">
|
||||||
|
Rule Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="ingestionRule"
|
||||||
|
placeholder={`Example for Gmail: "Only ingest emails from the last 24 hours that contain the word 'urgent' or 'important' in the subject line or body. Skip promotional emails and newsletters. Focus on emails from known contacts or business domains."`}
|
||||||
|
value={ingestionRuleText}
|
||||||
|
onChange={(e) => setIngestionRuleText(e.target.value)}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Describe what data should be ingested from this integration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
disabled={
|
||||||
|
!ingestionRuleText.trim() ||
|
||||||
|
ingestionRuleFetcher.state === "submitting"
|
||||||
|
}
|
||||||
|
onClick={handleIngestionRuleUpdate}
|
||||||
|
>
|
||||||
|
{ingestionRuleFetcher.state === "submitting"
|
||||||
|
? "Updating..."
|
||||||
|
: "Update Rule"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ export function IntegrationGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="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) => {
|
{integrations.map((integration) => {
|
||||||
const isConnected = hasActiveAccount(integration.id);
|
const isConnected = hasActiveAccount(integration.id);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
interface OAuthAuthSectionProps {
|
||||||
|
integration: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
specData: any;
|
||||||
|
activeAccount: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OAuthAuthSection({
|
||||||
|
integration,
|
||||||
|
specData,
|
||||||
|
activeAccount,
|
||||||
|
}: OAuthAuthSectionProps) {
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (activeAccount || !specData?.auth?.OAuth2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background-3 rounded-lg p-4">
|
||||||
|
<h4 className="mb-3 font-medium">OAuth 2.0 Authentication</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={isConnecting || oauthFetcher.state === "submitting"}
|
||||||
|
onClick={handleOAuthConnect}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isConnecting || oauthFetcher.state === "submitting"
|
||||||
|
? "Connecting..."
|
||||||
|
: `Connect to ${integration.name}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/webapp/app/components/integrations/section.tsx
Normal file
33
apps/webapp/app/components/integrations/section.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
interface SectionProps {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
metadata?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
metadata,
|
||||||
|
children,
|
||||||
|
}: SectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="flex w-[400px] shrink-0 flex-col">
|
||||||
|
{icon && <>{icon}</>}
|
||||||
|
<h3 className="text-lg"> {title} </h3>
|
||||||
|
<p className="text-muted-foreground">{description}</p>
|
||||||
|
{metadata ? metadata : null}
|
||||||
|
</div>
|
||||||
|
<div className="grow">
|
||||||
|
<div className="flex h-full w-full justify-center">
|
||||||
|
<div className="flex h-full max-w-[76ch] grow flex-col gap-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,10 +12,81 @@ import {
|
|||||||
import { type LogItem } from "~/hooks/use-logs";
|
import { type LogItem } from "~/hooks/use-logs";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react";
|
import { AlertCircle, ChevronDown, XCircle } from "lucide-react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { ScrollManagedList } from "../virtualized-list";
|
import { ScrollManagedList } from "../virtualized-list";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
|
||||||
|
// --- LogTextCollapse component ---
|
||||||
|
function LogTextCollapse({ text }: { text?: string }) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Show collapse if text is long (by word count)
|
||||||
|
const COLLAPSE_WORD_LIMIT = 30;
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground mb-2 text-xs italic">
|
||||||
|
No log details.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by words for word count
|
||||||
|
const words = text.split(/\s+/);
|
||||||
|
const isLong = words.length > COLLAPSE_WORD_LIMIT;
|
||||||
|
|
||||||
|
let displayText: string;
|
||||||
|
if (isLong) {
|
||||||
|
displayText = words.slice(0, COLLAPSE_WORD_LIMIT).join(" ") + " ...";
|
||||||
|
} else {
|
||||||
|
displayText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"whitespace-p-wrap pt-2 text-sm break-words",
|
||||||
|
isLong ? "max-h-16 overflow-hidden" : "",
|
||||||
|
)}
|
||||||
|
style={{ lineHeight: "1.5" }}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
{isLong && (
|
||||||
|
<>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl p-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex w-full items-center justify-between">
|
||||||
|
<span>Log Details</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[70vh] overflow-auto p-0">
|
||||||
|
<p
|
||||||
|
className="px-3 py-2 text-sm break-words whitespace-pre-wrap"
|
||||||
|
style={{ lineHeight: "1.5" }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface VirtualLogsListProps {
|
interface VirtualLogsListProps {
|
||||||
logs: LogItem[];
|
logs: LogItem[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@ -88,7 +159,8 @@ function LogItemRenderer(
|
|||||||
getStatusColor(log.status),
|
getStatusColor(log.status),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{log.status.toLowerCase()}
|
{log.status.charAt(0).toUpperCase() +
|
||||||
|
log.status.slice(1).toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -97,9 +169,7 @@ function LogItemRenderer(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2">
|
<LogTextCollapse text={log.ingestText} />
|
||||||
<p className="text-foreground text-sm">{log.ingestText}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const PAGE_TITLES: Record<string, string> = {
|
|||||||
"/home/dashboard": "Memory graph",
|
"/home/dashboard": "Memory graph",
|
||||||
"/home/conversation": "Conversation",
|
"/home/conversation": "Conversation",
|
||||||
"/home/integrations": "Integrations",
|
"/home/integrations": "Integrations",
|
||||||
|
"/home/integration": "Integrations",
|
||||||
"/home/logs": "Logs",
|
"/home/logs": "Logs",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -54,7 +55,7 @@ function NavigationBackForward() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mr-1 flex items-center">
|
<div className="mr-1 flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { runIntegrationTrigger } from "~/services/integration.server";
|
|||||||
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
|
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
|
||||||
import { logger } from "~/services/logger.service";
|
import { logger } from "~/services/logger.service";
|
||||||
import { getWorkspaceByUser } from "~/models/workspace.server";
|
import { getWorkspaceByUser } from "~/models/workspace.server";
|
||||||
|
import { tasks } from "@trigger.dev/sdk";
|
||||||
|
import { scheduler } from "~/trigger/integrations/scheduler";
|
||||||
|
|
||||||
// Schema for creating an integration account with API key
|
// Schema for creating an integration account with API key
|
||||||
const IntegrationAccountBodySchema = z.object({
|
const IntegrationAccountBodySchema = z.object({
|
||||||
@ -61,6 +63,10 @@ const { action, loader } = createHybridActionApiRoute(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tasks.trigger<typeof scheduler>("scheduler", {
|
||||||
|
integrationAccountId: setupResult?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return json({ success: true, setupResult });
|
return json({ success: true, setupResult });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating integration account", {
|
logger.error("Error creating integration account", {
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
import React, { useMemo, useState, useCallback } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
import {
|
||||||
import { useLoaderData, useFetcher } from "@remix-run/react";
|
json,
|
||||||
|
type LoaderFunctionArgs,
|
||||||
|
type ActionFunctionArgs,
|
||||||
|
} from "@remix-run/node";
|
||||||
|
import { useLoaderData } from "@remix-run/react";
|
||||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { getIcon, type IconType } from "~/components/icon-utils";
|
import { getIcon, type IconType } from "~/components/icon-utils";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { MCPAuthSection } from "~/components/integrations/mcp-auth-section";
|
import { MCPAuthSection } from "~/components/integrations/mcp-auth-section";
|
||||||
import { ConnectedAccountSection } from "~/components/integrations/connected-account-section";
|
import { ConnectedAccountSection } from "~/components/integrations/connected-account-section";
|
||||||
|
import { IngestionRuleSection } from "~/components/integrations/ingestion-rule-section";
|
||||||
|
import { ApiKeyAuthSection } from "~/components/integrations/api-key-auth-section";
|
||||||
|
import { OAuthAuthSection } from "~/components/integrations/oauth-auth-section";
|
||||||
|
import {
|
||||||
|
getIngestionRuleBySource,
|
||||||
|
upsertIngestionRule,
|
||||||
|
} from "~/services/ingestionRule.server";
|
||||||
|
import { Section } from "~/components/integrations/section";
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -36,13 +39,72 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
|||||||
throw new Response("Integration not found", { status: 404 });
|
throw new Response("Integration not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeAccount = integrationAccounts.find(
|
||||||
|
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ingestionRule = null;
|
||||||
|
if (activeAccount) {
|
||||||
|
ingestionRule = await getIngestionRuleBySource(
|
||||||
|
activeAccount.id,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
integration,
|
integration,
|
||||||
integrationAccounts,
|
integrationAccounts,
|
||||||
userId,
|
userId,
|
||||||
|
ingestionRule,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: ActionFunctionArgs) {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
const workspace = await requireWorkpace(request);
|
||||||
|
const { slug } = params;
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const ingestionRuleText = formData.get("ingestionRule") as string;
|
||||||
|
|
||||||
|
if (!ingestionRuleText) {
|
||||||
|
return json({ error: "Ingestion rule is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeAccount = integrationAccounts.find(
|
||||||
|
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeAccount) {
|
||||||
|
return json(
|
||||||
|
{ error: "No active integration account found" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertIngestionRule({
|
||||||
|
text: ingestionRuleText,
|
||||||
|
source: activeAccount.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
function parseSpec(spec: any) {
|
function parseSpec(spec: any) {
|
||||||
if (!spec) return {};
|
if (!spec) return {};
|
||||||
if (typeof spec === "string") {
|
if (typeof spec === "string") {
|
||||||
@ -56,14 +118,8 @@ function parseSpec(spec: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function IntegrationDetail() {
|
export default function IntegrationDetail() {
|
||||||
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
|
const { integration, integrationAccounts, ingestionRule } =
|
||||||
const [apiKey, setApiKey] = useState("");
|
useLoaderData<typeof loader>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
|
||||||
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
|
|
||||||
|
|
||||||
const apiKeyFetcher = useFetcher();
|
|
||||||
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
|
||||||
|
|
||||||
const activeAccount = useMemo(
|
const activeAccount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -82,208 +138,86 @@ export default function IntegrationDetail() {
|
|||||||
const hasMCPAuth = !!specData?.mcpAuth;
|
const hasMCPAuth = !!specData?.mcpAuth;
|
||||||
const Component = getIcon(integration.icon as IconType);
|
const Component = getIcon(integration.icon as IconType);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
<div className="p-4 px-5">
|
||||||
{/* Integration Details */}
|
<Section
|
||||||
<div className="mx-auto w-2xl space-y-6">
|
title={integration.name}
|
||||||
<Card>
|
description={integration.description}
|
||||||
<CardHeader className="bg-background-2">
|
icon={
|
||||||
<div className="flex items-start gap-4">
|
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||||
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
<Component size={24} />
|
||||||
<Component size={24} />
|
</div>
|
||||||
</div>
|
}
|
||||||
<div className="-mt-1 flex-1">
|
>
|
||||||
<CardTitle className="text-2xl">{integration.name}</CardTitle>
|
<div>
|
||||||
<CardDescription className="text-base">
|
{/* Authentication Methods */}
|
||||||
{integration.description || `Connect to ${integration.name}`}
|
<div className="space-y-4">
|
||||||
</CardDescription>
|
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
{hasApiKey && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked /> API Key authentication
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasOAuth2 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked />
|
||||||
|
OAuth 2.0 authentication
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
No authentication method specified
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
|
||||||
<CardContent className="bg-background-2 p-4">
|
{/* Connect Section */}
|
||||||
{/* Authentication Methods */}
|
{!activeAccount && (hasApiKey || hasOAuth2) && (
|
||||||
<div className="space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
<h3 className="text-lg font-medium">
|
||||||
<div className="space-y-2">
|
Connect to {integration.name}
|
||||||
{hasApiKey && (
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="inline-flex items-center gap-2 text-sm">
|
{/* API Key Authentication */}
|
||||||
<Checkbox checked /> API Key authentication
|
<ApiKeyAuthSection
|
||||||
</span>
|
integration={integration}
|
||||||
</div>
|
specData={specData}
|
||||||
)}
|
activeAccount={activeAccount}
|
||||||
{hasOAuth2 && (
|
/>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="inline-flex items-center gap-2 text-sm">
|
{/* OAuth Authentication */}
|
||||||
<Checkbox checked />
|
<OAuthAuthSection
|
||||||
OAuth 2.0 authentication
|
integration={integration}
|
||||||
</span>
|
specData={specData}
|
||||||
</div>
|
activeAccount={activeAccount}
|
||||||
)}
|
/>
|
||||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
No authentication method specified
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Connect Section */}
|
{/* Connected Account Info */}
|
||||||
{!activeAccount && (hasApiKey || hasOAuth2) && (
|
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
<h3 className="text-lg font-medium">
|
|
||||||
Connect to {integration.name}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* API Key Authentication */}
|
{/* MCP Authentication Section */}
|
||||||
{hasApiKey && (
|
<MCPAuthSection
|
||||||
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
integration={integration}
|
||||||
<h4 className="font-medium">API Key Authentication</h4>
|
activeAccount={activeAccount as any}
|
||||||
{!showApiKeyForm ? (
|
hasMCPAuth={hasMCPAuth}
|
||||||
<Button
|
/>
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setShowApiKeyForm(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Connect with API Key
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
htmlFor="apiKey"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{specData?.auth?.api_key?.label || "API Key"}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="apiKey"
|
|
||||||
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>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setShowApiKeyForm(false);
|
|
||||||
setApiKey("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
disabled={isLoading || !apiKey.trim()}
|
|
||||||
onClick={handleApiKeyConnect}
|
|
||||||
>
|
|
||||||
{isLoading || apiKeyFetcher.state === "submitting"
|
|
||||||
? "Connecting..."
|
|
||||||
: "Connect"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* OAuth Authentication */}
|
{/* Ingestion Rule Section */}
|
||||||
{hasOAuth2 && (
|
<IngestionRuleSection
|
||||||
<div className="bg-background-3 rounded-lg p-4">
|
ingestionRule={ingestionRule}
|
||||||
<h4 className="mb-3 font-medium">
|
activeAccount={activeAccount}
|
||||||
OAuth 2.0 Authentication
|
/>
|
||||||
</h4>
|
</div>
|
||||||
<Button
|
</Section>
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={
|
|
||||||
isConnecting || oauthFetcher.state === "submitting"
|
|
||||||
}
|
|
||||||
onClick={handleOAuthConnect}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isConnecting || oauthFetcher.state === "submitting"
|
|
||||||
? "Connecting..."
|
|
||||||
: `Connect to ${integration.name}`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Connected Account Info */}
|
|
||||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
|
||||||
|
|
||||||
{/* MCP Authentication Section */}
|
|
||||||
<MCPAuthSection
|
|
||||||
integration={integration}
|
|
||||||
activeAccount={activeAccount as any}
|
|
||||||
hasMCPAuth={hasMCPAuth}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,10 +39,6 @@ export default function Integrations() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
||||||
<div className="space-y-1 text-base">
|
|
||||||
<p className="text-muted-foreground">Connect your tools and services</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IntegrationGrid
|
<IntegrationGrid
|
||||||
integrations={integrationDefinitions}
|
integrations={integrationDefinitions}
|
||||||
activeAccountIds={activeAccountIds}
|
activeAccountIds={activeAccountIds}
|
||||||
|
|||||||
56
apps/webapp/app/services/ingestionRule.server.ts
Normal file
56
apps/webapp/app/services/ingestionRule.server.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { prisma } from "~/db.server";
|
||||||
|
|
||||||
|
export async function getIngestionRuleBySource(
|
||||||
|
source: string,
|
||||||
|
workspaceId: string,
|
||||||
|
) {
|
||||||
|
return await prisma.ingestionRule.findFirst({
|
||||||
|
where: {
|
||||||
|
source,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to fix this later
|
||||||
|
export async function upsertIngestionRule({
|
||||||
|
text,
|
||||||
|
source,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
source: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
// Find existing rule first
|
||||||
|
const existingRule = await prisma.ingestionRule.findFirst({
|
||||||
|
where: {
|
||||||
|
source,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRule) {
|
||||||
|
// Update existing rule
|
||||||
|
return await prisma.ingestionRule.update({
|
||||||
|
where: {
|
||||||
|
id: existingRule.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new rule
|
||||||
|
return await prisma.ingestionRule.create({
|
||||||
|
data: {
|
||||||
|
text,
|
||||||
|
source,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user