diff --git a/apps/webapp/app/components/logs/logs-filters.tsx b/apps/webapp/app/components/logs/logs-filters.tsx
new file mode 100644
index 0000000..0263a09
--- /dev/null
+++ b/apps/webapp/app/components/logs/logs-filters.tsx
@@ -0,0 +1,228 @@
+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";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "~/components/ui/popover";
+import { Badge } from "~/components/ui/badge";
+import { cn } from "~/lib/utils";
+
+interface LogsFiltersProps {
+ availableSources: Array<{ name: string; slug: string }>;
+ selectedSource?: string;
+ selectedStatus?: string;
+ onSourceChange: (source?: string) => void;
+ onStatusChange: (status?: string) => void;
+}
+
+const statusOptions = [
+ { value: "PENDING", label: "Pending" },
+ { value: "PROCESSING", label: "Processing" },
+ { value: "COMPLETED", label: "Completed" },
+ { value: "FAILED", label: "Failed" },
+ { value: "CANCELLED", label: "Cancelled" },
+];
+
+export function LogsFilters({
+ availableSources,
+ selectedSource,
+ selectedStatus,
+ onSourceChange,
+ onStatusChange,
+}: LogsFiltersProps) {
+ const [sourceOpen, setSourceOpen] = useState(false);
+ const [statusOpen, setStatusOpen] = useState(false);
+
+ const selectedSourceName = availableSources.find(
+ (s) => s.slug === selectedSource,
+ )?.name;
+ const selectedStatusLabel = statusOptions.find(
+ (s) => s.value === selectedStatus,
+ )?.label;
+
+ const clearFilters = () => {
+ onSourceChange(undefined);
+ onStatusChange(undefined);
+ };
+
+ const hasFilters = selectedSource || selectedStatus;
+
+ return (
+
+
+
+ Filters:
+
+
+ {/* Source Filter */}
+
+
+
+
+
+
+
+
+ No sources found.
+
+ {
+ onSourceChange(undefined);
+ setSourceOpen(false);
+ }}
+ >
+
+ All sources
+
+ {availableSources.map((source) => (
+ {
+ onSourceChange(
+ source.slug === selectedSource
+ ? undefined
+ : source.slug,
+ );
+ setSourceOpen(false);
+ }}
+ >
+
+ {source.name}
+
+ ))}
+
+
+
+
+
+
+ {/* Status Filter */}
+
+
+
+
+
+
+
+
+ No status found.
+
+ {
+ onStatusChange(undefined);
+ setStatusOpen(false);
+ }}
+ >
+
+ All statuses
+
+ {statusOptions.map((status) => (
+ {
+ onStatusChange(
+ status.value === selectedStatus
+ ? undefined
+ : status.value,
+ );
+ setStatusOpen(false);
+ }}
+ >
+
+ {status.label}
+
+ ))}
+
+
+
+
+
+
+ {/* Active Filters */}
+ {hasFilters && (
+
+ {selectedSource && (
+
+ {selectedSourceName}
+ onSourceChange(undefined)}
+ />
+
+ )}
+ {selectedStatus && (
+
+ {selectedStatusLabel}
+ onStatusChange(undefined)}
+ />
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/apps/webapp/app/components/logs/virtual-logs-list.tsx b/apps/webapp/app/components/logs/virtual-logs-list.tsx
new file mode 100644
index 0000000..2878281
--- /dev/null
+++ b/apps/webapp/app/components/logs/virtual-logs-list.tsx
@@ -0,0 +1,198 @@
+import { useEffect, useRef, useState } from "react";
+import { List, InfiniteLoader, WindowScroller } from "react-virtualized";
+import { 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";
+
+interface VirtualLogsListProps {
+ logs: LogItem[];
+ hasMore: boolean;
+ loadMore: () => void;
+ isLoading: boolean;
+ 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;
+ const log = logs[index];
+
+ if (!log) {
+ return (
+
+ );
+ }
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case "PROCESSING":
+ return ;
+ case "PENDING":
+ return ;
+ case "COMPLETED":
+ return ;
+ case "FAILED":
+ return ;
+ case "CANCELLED":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "PROCESSING":
+ return "bg-blue-100 text-blue-800";
+ case "PENDING":
+ return "bg-yellow-100 text-yellow-800";
+ case "COMPLETED":
+ return "bg-green-100 text-green-800";
+ case "FAILED":
+ return "bg-red-100 text-red-800";
+ case "CANCELLED":
+ return "bg-gray-100 text-gray-800";
+ default:
+ return "bg-gray-100 text-gray-800";
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {log.source}
+
+
+ {getStatusIcon(log.status)}
+
+ {log.status.toLowerCase()}
+
+
+
+
+ {new Date(log.time).toLocaleString()}
+
+
+
+
+
+
+
+ {log.sourceURL && (
+
+ Source URL
+
+ )}
+ {log.processedAt && (
+
+ Processed: {new Date(log.processedAt).toLocaleString()}
+
+ )}
+
+
+ {log.error && (
+
+ )}
+
+
+
+
+ );
+}
+
+export function VirtualLogsList({
+ logs,
+ hasMore,
+ loadMore,
+ isLoading,
+ height = 600,
+}: VirtualLogsListProps) {
+ const [containerHeight, setContainerHeight] = useState(height);
+
+ 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]);
+
+ const isRowLoaded = ({ index }: { index: number }) => {
+ return !!logs[index];
+ };
+
+ const loadMoreRows = async () => {
+ if (hasMore) {
+ return loadMore();
+ }
+
+ return false;
+ };
+
+ const rowRenderer = (props: LogItemRendererProps) => {
+ return LogItemRenderer(props, logs);
+ };
+
+ const itemCount = hasMore ? logs.length + 1 : logs.length;
+
+ return (
+
+
+ {({ onRowsRendered, registerChild }) => (
+
+ )}
+
+
+ {isLoading && (
+
+ Loading more logs...
+
+ )}
+
+ );
+}
diff --git a/apps/webapp/app/components/sidebar/app-sidebar.tsx b/apps/webapp/app/components/sidebar/app-sidebar.tsx
index 62400b9..426ae5e 100644
--- a/apps/webapp/app/components/sidebar/app-sidebar.tsx
+++ b/apps/webapp/app/components/sidebar/app-sidebar.tsx
@@ -28,8 +28,8 @@ const data = {
icon: Network,
},
{
- title: "Activity",
- url: "/home/activity",
+ title: "Logs",
+ url: "/home/logs/all",
icon: Activity,
},
{
diff --git a/apps/webapp/app/components/ui/command.tsx b/apps/webapp/app/components/ui/command.tsx
new file mode 100644
index 0000000..307f965
--- /dev/null
+++ b/apps/webapp/app/components/ui/command.tsx
@@ -0,0 +1,179 @@
+import { type DialogProps } from "@radix-ui/react-dialog";
+import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
+import { Command as CommandPrimitive } from "cmdk";
+import React from "react";
+
+import { Dialog, DialogContent } from "./dialog";
+import { cn } from "../../lib/utils";
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = CommandPrimitive.displayName;
+
+interface CommandDialogProps extends DialogProps {
+ commandProps?: React.ComponentPropsWithoutRef;
+}
+
+interface CommandInputProps
+ extends React.ComponentPropsWithoutRef {
+ icon?: boolean;
+ containerClassName?: string;
+}
+
+const CommandDialog = ({
+ children,
+ commandProps,
+ ...props
+}: CommandDialogProps) => {
+ return (
+
+ );
+};
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ CommandInputProps
+>(({ className, icon, containerClassName, ...props }, ref) => (
+
+ {icon && (
+
+ )}
+
+
+));
+
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+CommandShortcut.displayName = "CommandShortcut";
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/apps/webapp/app/components/ui/header.tsx b/apps/webapp/app/components/ui/header.tsx
index a45252a..b991f86 100644
--- a/apps/webapp/app/components/ui/header.tsx
+++ b/apps/webapp/app/components/ui/header.tsx
@@ -7,7 +7,7 @@ const PAGE_TITLES: Record = {
"/home/dashboard": "Memory graph",
"/home/conversation": "Conversation",
"/home/integrations": "Integrations",
- "/home/activity": "Activity",
+ "/home/logs": "Logs",
};
function getHeaderTitle(pathname: string): string {
@@ -26,12 +26,17 @@ function isConversationDetail(pathname: string): boolean {
return /^\/home\/conversation\/[^/]+$/.test(pathname);
}
+function isIntegrationsPage(pathname: string): boolean {
+ return pathname === "/home/integrations";
+}
+
export function SiteHeader() {
const location = useLocation();
const navigate = useNavigate();
const title = getHeaderTitle(location.pathname);
const showNewConversationButton = isConversationDetail(location.pathname);
+ const showRequestIntegrationButton = isIntegrationsPage(location.pathname);
return (
@@ -51,6 +56,16 @@ export function SiteHeader() {
New conversation
)}
+ {showRequestIntegrationButton && (
+
+ )}
diff --git a/apps/webapp/app/components/ui/sidebar.tsx b/apps/webapp/app/components/ui/sidebar.tsx
index b2df014..6cbd3c0 100644
--- a/apps/webapp/app/components/ui/sidebar.tsx
+++ b/apps/webapp/app/components/ui/sidebar.tsx
@@ -263,7 +263,8 @@ function SidebarTrigger({
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
- className={cn("size-8", className)}
+ size="xs"
+ className={cn("size-6 rounded-md", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
diff --git a/apps/webapp/app/hooks/use-ingestion-status.tsx b/apps/webapp/app/hooks/use-ingestion-status.tsx
index 597a6ab..a55c6d9 100644
--- a/apps/webapp/app/hooks/use-ingestion-status.tsx
+++ b/apps/webapp/app/hooks/use-ingestion-status.tsx
@@ -36,7 +36,7 @@ export function useIngestionStatus() {
clearInterval(interval);
setIsPolling(false);
};
- }, [fetcher]);
+ }, []); // Remove fetcher from dependencies to prevent infinite loop
return {
data: fetcher.data,
diff --git a/apps/webapp/app/hooks/use-logs.tsx b/apps/webapp/app/hooks/use-logs.tsx
new file mode 100644
index 0000000..fb934b0
--- /dev/null
+++ b/apps/webapp/app/hooks/use-logs.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useState, useCallback } from "react";
+import { useFetcher } from "@remix-run/react";
+
+export interface LogItem {
+ id: string;
+ source: string;
+ ingestText: string;
+ time: string;
+ processedAt?: string;
+ status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED" | "CANCELLED";
+ error?: string;
+ sourceURL?: string;
+ integrationSlug?: string;
+ activityId?: string;
+}
+
+export interface LogsResponse {
+ logs: LogItem[];
+ totalCount: number;
+ page: number;
+ limit: number;
+ hasMore: boolean;
+ availableSources: Array<{ name: string; slug: string }>;
+}
+
+export interface UseLogsOptions {
+ endpoint: string; // '/api/v1/logs/all' or '/api/v1/logs/activity'
+ source?: string;
+ status?: string;
+}
+
+export function useLogs({ endpoint, source, status }: UseLogsOptions) {
+ const fetcher = useFetcher();
+ const [logs, setLogs] = useState([]);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [availableSources, setAvailableSources] = useState>([]);
+ 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 loadMore = useCallback(() => {
+ if (fetcher.state === 'idle' && hasMore) {
+ fetcher.load(buildUrl(page + 1));
+ }
+ }, [hasMore, page, buildUrl]);
+
+ const reset = useCallback(() => {
+ setLogs([]);
+ setPage(1);
+ setHasMore(true);
+ setIsInitialLoad(true);
+ fetcher.load(buildUrl(1));
+ }, [buildUrl]);
+
+ // Effect to handle fetcher data
+ useEffect(() => {
+ if (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]);
+ }
+
+ setHasMore(newHasMore);
+ setPage(currentPage);
+ setAvailableSources(sources);
+ }
+ }, [fetcher.data]);
+
+ // Effect to reset when filters change
+ useEffect(() => {
+ setLogs([]);
+ setPage(1);
+ setHasMore(true);
+ setIsInitialLoad(true);
+ fetcher.load(buildUrl(1));
+ }, [source, status, buildUrl]); // Inline reset logic to avoid dependency issues
+
+ // Initial load
+ useEffect(() => {
+ if (isInitialLoad) {
+ fetcher.load(buildUrl(1));
+ }
+ }, [isInitialLoad, buildUrl]);
+
+ return {
+ logs,
+ hasMore,
+ loadMore,
+ reset,
+ availableSources,
+ isLoading: fetcher.state === 'loading',
+ isInitialLoad,
+ };
+}
diff --git a/apps/webapp/app/lib/ingest.server.ts b/apps/webapp/app/lib/ingest.server.ts
index 3c97ddb..03f130c 100644
--- a/apps/webapp/app/lib/ingest.server.ts
+++ b/apps/webapp/app/lib/ingest.server.ts
@@ -16,6 +16,7 @@ export const IngestBodyRequest = z.object({
export const addToQueue = async (
body: z.infer,
userId: string,
+ activityId?: string,
) => {
const user = await prisma.user.findFirst({
where: {
@@ -39,6 +40,7 @@ export const addToQueue = async (
status: IngestionStatus.PENDING,
priority: 1,
workspaceId: user.Workspace.id,
+ activityId,
},
});
diff --git a/apps/webapp/app/routes/api.v1.activity.tsx b/apps/webapp/app/routes/api.v1.activity.tsx
index 18e5357..7cadb22 100644
--- a/apps/webapp/app/routes/api.v1.activity.tsx
+++ b/apps/webapp/app/routes/api.v1.activity.tsx
@@ -40,7 +40,6 @@ const { action, loader } = createActionApiRoute(
throw new Error("User not found");
}
-
// Create the activity record
const activity = await prisma.activity.create({
data: {
@@ -64,7 +63,11 @@ const { action, loader } = createActionApiRoute(
},
};
- const queueResponse = await addToQueue(ingestData, authentication.userId);
+ const queueResponse = await addToQueue(
+ ingestData,
+ authentication.userId,
+ activity.id,
+ );
logger.log("Activity created and queued for ingestion", {
activityId: activity.id,
@@ -90,4 +93,4 @@ const { action, loader } = createActionApiRoute(
},
);
-export { action, loader };
\ No newline at end of file
+export { action, loader };
diff --git a/apps/webapp/app/routes/api.v1.logs.activity.tsx b/apps/webapp/app/routes/api.v1.logs.activity.tsx
new file mode 100644
index 0000000..79cca5f
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.logs.activity.tsx
@@ -0,0 +1,130 @@
+import { 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");
+ const status = url.searchParams.get("status");
+ const skip = (page - 1) * limit;
+
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ include: { Workspace: true },
+ });
+
+ if (!user?.Workspace) {
+ throw new Response("Workspace not found", { status: 404 });
+ }
+
+ // Build where clause for filtering - only items with activityId
+ const whereClause: any = {
+ workspaceId: user.Workspace.id,
+ activityId: {
+ not: null,
+ },
+ };
+
+ if (status) {
+ whereClause.status = status;
+ }
+
+ // If source filter is provided, we need to filter by integration source
+ if (source) {
+ whereClause.activity = {
+ integrationAccount: {
+ integrationDefinition: {
+ slug: source,
+ },
+ },
+ };
+ }
+
+ const [logs, totalCount] = await Promise.all([
+ prisma.ingestionQueue.findMany({
+ where: whereClause,
+ include: {
+ activity: {
+ include: {
+ integrationAccount: {
+ include: {
+ integrationDefinition: {
+ select: {
+ name: true,
+ slug: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ skip,
+ take: limit,
+ }),
+ prisma.ingestionQueue.count({
+ where: whereClause,
+ }),
+ ]);
+
+ // Get available sources for filtering (only those with activities)
+ const availableSources = await prisma.integrationDefinitionV2.findMany({
+ where: {
+ IntegrationAccount: {
+ some: {
+ workspaceId: user.Workspace.id,
+ Activity: {
+ some: {
+ IngestionQueue: {
+ some: {
+ activityId: {
+ not: null,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ select: {
+ name: true,
+ slug: true,
+ },
+ });
+
+ // 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',
+ time: log.createdAt,
+ processedAt: log.processedAt,
+ status: log.status,
+ error: log.error,
+ sourceURL: log.activity?.sourceURL,
+ integrationSlug: log.activity?.integrationAccount?.integrationDefinition?.slug,
+ activityId: log.activityId,
+ }));
+
+ return json({
+ logs: formattedLogs,
+ totalCount,
+ page,
+ limit,
+ hasMore: skip + logs.length < totalCount,
+ availableSources,
+ });
+}
diff --git a/apps/webapp/app/routes/api.v1.logs.all.tsx b/apps/webapp/app/routes/api.v1.logs.all.tsx
new file mode 100644
index 0000000..71aeb89
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.logs.all.tsx
@@ -0,0 +1,137 @@
+import { type LoaderFunctionArgs, json } from "@remix-run/node";
+import { prisma } from "~/db.server";
+import { requireUserId } from "~/services/session.server";
+
+/**
+ * Optimizations:
+ * - Use `findMany` with `select` instead of `include` to fetch only required fields.
+ * - Use `count` with the same where clause, but only after fetching logs (to avoid unnecessary count if no logs).
+ * - Use a single query for availableSources with minimal fields.
+ * - Avoid unnecessary object spreading and type casting.
+ * - Minimize nested object traversal in mapping.
+ */
+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");
+ const status = url.searchParams.get("status");
+ const skip = (page - 1) * limit;
+
+ // Get user and workspace in one query
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { Workspace: { select: { id: true } } },
+ });
+
+ if (!user?.Workspace) {
+ throw new Response("Workspace not found", { status: 404 });
+ }
+
+ // Build where clause for filtering
+ const whereClause: any = {
+ workspaceId: user.Workspace.id,
+ };
+
+ if (status) {
+ whereClause.status = status;
+ }
+
+ // If source filter is provided, filter by integration source
+ if (source) {
+ whereClause.activity = {
+ integrationAccount: {
+ integrationDefinition: {
+ slug: source,
+ },
+ },
+ };
+ }
+
+ // Use select to fetch only required fields for logs
+ const [logs, totalCount, availableSources] = await Promise.all([
+ prisma.ingestionQueue.findMany({
+ where: whereClause,
+ select: {
+ id: true,
+ createdAt: true,
+ processedAt: true,
+ status: true,
+ error: true,
+ data: true,
+ activity: {
+ select: {
+ text: true,
+ sourceURL: true,
+ integrationAccount: {
+ select: {
+ integrationDefinition: {
+ select: {
+ name: true,
+ slug: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ skip,
+ take: limit,
+ }),
+
+ prisma.ingestionQueue.count({
+ where: whereClause,
+ }),
+
+ prisma.integrationDefinitionV2.findMany({
+ where: {
+ IntegrationAccount: {
+ some: {
+ workspaceId: user.Workspace.id,
+ },
+ },
+ },
+ select: {
+ name: true,
+ slug: true,
+ },
+ }),
+ ]);
+
+ // Format the response
+ const formattedLogs = logs.map((log) => {
+ const integrationDef =
+ log.activity?.integrationAccount?.integrationDefinition;
+ const logData = log.data as any;
+ return {
+ id: log.id,
+ source: integrationDef?.name || logData?.source || "Unknown",
+ ingestText:
+ log.activity?.text ||
+ logData?.episodeBody ||
+ logData?.text ||
+ "No content",
+ time: log.createdAt,
+ processedAt: log.processedAt,
+ status: log.status,
+ error: log.error,
+ sourceURL: log.activity?.sourceURL,
+ integrationSlug: integrationDef?.slug,
+ };
+ });
+
+ return json({
+ logs: formattedLogs,
+ totalCount,
+ page,
+ limit,
+ hasMore: skip + logs.length < totalCount,
+ availableSources,
+ });
+}
diff --git a/apps/webapp/app/routes/home.integrations.tsx b/apps/webapp/app/routes/home.integrations.tsx
index f509e8a..335b098 100644
--- a/apps/webapp/app/routes/home.integrations.tsx
+++ b/apps/webapp/app/routes/home.integrations.tsx
@@ -6,19 +6,33 @@ import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
-import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
+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 {
+ 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";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
// Loader to fetch integration definitions and existing accounts
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const workspace = await requireWorkpace(request);
-
+
const [integrationDefinitions, integrationAccounts] = await Promise.all([
getIntegrationDefinitions(workspace.id),
getIntegrationAccounts(userId),
@@ -32,46 +46,26 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Integrations() {
- const { integrationDefinitions, integrationAccounts, userId } = useLoaderData();
- const [selectedCategory, setSelectedCategory] = useState("all");
+ const { integrationDefinitions, integrationAccounts, userId } =
+ useLoaderData();
const [selectedIntegration, setSelectedIntegration] = useState(null);
const [apiKey, setApiKey] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
- // Extract categories from integration definitions
- const categories = Array.from(
- new Set(integrationDefinitions.map((integration) => {
- const specData = typeof integration.spec === 'string'
- ? JSON.parse(integration.spec)
- : integration.spec;
- return specData?.category || "Uncategorized";
- }))
- );
-
- // Filter integrations by selected category
- const filteredIntegrations = selectedCategory === "all"
- ? integrationDefinitions
- : integrationDefinitions.filter(
- (integration) => {
- const specData = typeof integration.spec === 'string'
- ? JSON.parse(integration.spec)
- : integration.spec;
- return specData?.category === selectedCategory;
- }
- );
-
// Check if user has an active account for an integration
const hasActiveAccount = (integrationDefinitionId: string) => {
return integrationAccounts.some(
- (account) => account.integrationDefinitionId === integrationDefinitionId && account.isActive
+ (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", {
@@ -84,12 +78,12 @@ export default function Integrations() {
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) {
@@ -103,7 +97,7 @@ export default function Integrations() {
// Handle OAuth connection
const handleOAuthConnect = async () => {
if (!selectedIntegration) return;
-
+
setIsConnecting(true);
try {
const response = await fetch("/api/v1/oauth", {
@@ -116,12 +110,12 @@ export default function Integrations() {
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;
@@ -134,117 +128,93 @@ export default function Integrations() {
};
return (
-
-
-
-
Integrations
-
Connect your tools and services
-
-
- {/* Category filter */}
-
-
- {/* Add integration button */}
-
-
+
+
+
Connect your tools and services
-
+
{/* Integration cards grid */}
- {filteredIntegrations.map((integration) => {
+ {integrationDefinitions.map((integration) => {
const isConnected = hasActiveAccount(integration.id);
- const authType = integration.spec?.auth?.type || "unknown";
-
+
return (
-