Fix: UI for integrations and logs

This commit is contained in:
Harshith Mullapudi 2025-07-16 22:13:18 +05:30
parent 32379a0b0e
commit 78d401f618
11 changed files with 579 additions and 225 deletions

View 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>
);
}

View File

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

View File

@ -32,7 +32,7 @@ export function IntegrationGrid({
}
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) => {
const isConnected = hasActiveAccount(integration.id);

View File

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

View 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>
);
}

View File

@ -12,10 +12,81 @@ import {
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 { AlertCircle, ChevronDown, XCircle } from "lucide-react";
import { cn } from "~/lib/utils";
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 {
logs: LogItem[];
hasMore: boolean;
@ -88,7 +159,8 @@ function LogItemRenderer(
getStatusColor(log.status),
)}
>
{log.status.toLowerCase()}
{log.status.charAt(0).toUpperCase() +
log.status.slice(1).toLowerCase()}
</Badge>
</div>
</div>
@ -97,9 +169,7 @@ function LogItemRenderer(
</div>
</div>
<div className="mb-2">
<p className="text-foreground text-sm">{log.ingestText}</p>
</div>
<LogTextCollapse text={log.ingestText} />
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center gap-4">

View File

@ -7,6 +7,7 @@ const PAGE_TITLES: Record<string, string> = {
"/home/dashboard": "Memory graph",
"/home/conversation": "Conversation",
"/home/integrations": "Integrations",
"/home/integration": "Integrations",
"/home/logs": "Logs",
};
@ -54,7 +55,7 @@ function NavigationBackForward() {
const navigate = useNavigate();
return (
<div className="mr-1 flex items-center">
<div className="mr-1 flex items-center gap-1">
<Button
variant="ghost"
size="xs"

View File

@ -6,6 +6,8 @@ import { runIntegrationTrigger } from "~/services/integration.server";
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
import { logger } from "~/services/logger.service";
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
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 });
} catch (error) {
logger.error("Error creating integration account", {

View File

@ -1,22 +1,25 @@
import React, { useMemo, useState, useCallback } from "react";
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useFetcher } from "@remix-run/react";
import React, { useMemo } from "react";
import {
json,
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.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 { Checkbox } from "~/components/ui/checkbox";
import { MCPAuthSection } from "~/components/integrations/mcp-auth-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) {
const userId = await requireUserId(request);
@ -36,13 +39,72 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
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({
integration,
integrationAccounts,
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) {
if (!spec) return {};
if (typeof spec === "string") {
@ -56,14 +118,8 @@ function parseSpec(spec: any) {
}
export default function IntegrationDetail() {
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
const [apiKey, setApiKey] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
const apiKeyFetcher = useFetcher();
const oauthFetcher = useFetcher<{ redirectURL: string }>();
const { integration, integrationAccounts, ingestionRule } =
useLoaderData<typeof loader>();
const activeAccount = useMemo(
() =>
@ -82,208 +138,86 @@ export default function IntegrationDetail() {
const hasMCPAuth = !!specData?.mcpAuth;
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 (
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
{/* Integration Details */}
<div className="mx-auto w-2xl space-y-6">
<Card>
<CardHeader className="bg-background-2">
<div className="flex items-start gap-4">
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
<Component size={24} />
</div>
<div className="-mt-1 flex-1">
<CardTitle className="text-2xl">{integration.name}</CardTitle>
<CardDescription className="text-base">
{integration.description || `Connect to ${integration.name}`}
</CardDescription>
</div>
<div className="p-4 px-5">
<Section
title={integration.name}
description={integration.description}
icon={
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
<Component size={24} />
</div>
}
>
<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="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>
</CardHeader>
</div>
<CardContent className="bg-background-2 p-4">
{/* 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="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>
{/* Connect Section */}
{!activeAccount && (hasApiKey || hasOAuth2) && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">
Connect to {integration.name}
</h3>
{/* API Key Authentication */}
<ApiKeyAuthSection
integration={integration}
specData={specData}
activeAccount={activeAccount}
/>
{/* OAuth Authentication */}
<OAuthAuthSection
integration={integration}
specData={specData}
activeAccount={activeAccount}
/>
</div>
)}
{/* Connect Section */}
{!activeAccount && (hasApiKey || hasOAuth2) && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">
Connect to {integration.name}
</h3>
{/* Connected Account Info */}
<ConnectedAccountSection activeAccount={activeAccount} />
{/* API Key Authentication */}
{hasApiKey && (
<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>
)}
{/* MCP Authentication Section */}
<MCPAuthSection
integration={integration}
activeAccount={activeAccount as any}
hasMCPAuth={hasMCPAuth}
/>
{/* OAuth Authentication */}
{hasOAuth2 && (
<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>
)}
</div>
)}
{/* Connected Account Info */}
<ConnectedAccountSection activeAccount={activeAccount} />
{/* MCP Authentication Section */}
<MCPAuthSection
integration={integration}
activeAccount={activeAccount as any}
hasMCPAuth={hasMCPAuth}
/>
</CardContent>
</Card>
</div>
{/* Ingestion Rule Section */}
<IngestionRuleSection
ingestionRule={ingestionRule}
activeAccount={activeAccount}
/>
</div>
</Section>
</div>
);
}

View File

@ -39,10 +39,6 @@ export default function Integrations() {
return (
<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
integrations={integrationDefinitions}
activeAccountIds={activeAccountIds}

View 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,
},
});
}
}