diff --git a/src/app/api/tasks/[taskId]/route.ts b/src/app/api/tasks/[taskId]/route.ts new file mode 100644 index 0000000..8c43f4f --- /dev/null +++ b/src/app/api/tasks/[taskId]/route.ts @@ -0,0 +1,26 @@ +import { db } from "@/app/db/db"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ taskId: string }> } +) { + try { + const { taskId } = await params; + const taskSubTasks = await db + .select() + .from(subTasks) + .where(eq(subTasks.taskId, taskId)) + .orderBy(subTasks.updatedAt); + + return NextResponse.json(taskSubTasks); + } catch (error) { + console.error("Error fetching subtasks:", error); + return NextResponse.json( + { error: "Failed to fetch subtasks" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 0000000..d11a239 --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -0,0 +1,37 @@ +import { db } from "@/app/db/db"; +import { tasks } from "@/app/db/schema/tasks/tasks"; +import { desc, count } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get("limit") || "20"); + const offset = parseInt(searchParams.get("offset") || "0"); + + const allTasks = await db + .select() + .from(tasks) + .orderBy(desc(tasks.updatedAt)) + .limit(limit) + .offset(offset); + + const countResult = await db + .select({ count: count() }) + .from(tasks); + const total = countResult[0].count; + + return NextResponse.json({ + tasks: allTasks, + total, + limit, + offset, + }); + } catch (error) { + console.error("Error fetching tasks:", error); + return NextResponse.json( + { error: "Failed to fetch tasks" }, + { status: 500 } + ); + } +} diff --git a/src/app/iq/IQDashboard.tsx b/src/app/iq/IQDashboard.tsx new file mode 100644 index 0000000..262a35f --- /dev/null +++ b/src/app/iq/IQDashboard.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState, useEffect } from "react"; +import TaskList from "./TaskList"; +import SubtaskDetails from "./SubtaskDetails"; + +export interface Task { + id: string; + taskSource: string; + jobStarted: string | null; + jobCompleted: string | null; + status: string; + service: string | null; + updatedAt: string; +} + +export interface SubTask { + id: string; + taskId: string; + jobStarted: string | null; + jobCompleted: string | null; + status: string; + inputs: string | null; + outputs: string | null; + cloudLogsURL: string | null; + updatedAt: string; +} + +interface TasksResponse { + tasks: Task[]; + total: number; + limit: number; + offset: number; +} + +export default function IQDashboard() { + const [tasks, setTasks] = useState([]); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [subtasks, setSubtasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + const [limit, setLimit] = useState(20); + const [offset, setOffset] = useState(0); + const [loadingMore, setLoadingMore] = useState(false); + + // Fetch tasks with pagination + const fetchTasks = async (newLimit: number, newOffset: number) => { + try { + if (newOffset === 0) setLoading(true); + else setLoadingMore(true); + + const response = await fetch( + `/api/tasks?limit=${newLimit}&offset=${newOffset}` + ); + if (!response.ok) throw new Error("Failed to fetch tasks"); + const data: TasksResponse = await response.json(); + + if (newOffset === 0) { + setTasks(data.tasks); + } else { + setTasks((prev) => [...prev, ...data.tasks]); + } + + setTotal(data.total); + setLimit(data.limit); + setOffset(newOffset + data.tasks.length); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + if (newOffset === 0) setTasks([]); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + // Initial load (first 20) + useEffect(() => { + fetchTasks(20, 0); + }, []); + + const handleLoadMore = () => { + fetchTasks(20, offset); + }; + + const handleRefresh = () => { + setOffset(0); + fetchTasks(20, 0); + }; + + // Fetch subtasks when a task is selected + useEffect(() => { + if (!selectedTaskId) { + setSubtasks([]); + return; + } + + const fetchSubtasks = async () => { + try { + const response = await fetch(`/api/tasks/${selectedTaskId}`); + if (!response.ok) throw new Error("Failed to fetch subtasks"); + const data = await response.json(); + setSubtasks(data); + } catch (err) { + setSubtasks([]); + } + }; + + fetchSubtasks(); + }, [selectedTaskId]); + + return ( +
+ {/* Left sidebar - Task list */} +
+ +
+ + {/* Right side - Subtask details */} +
+ {selectedTaskId ? ( + t.id === selectedTaskId)} + /> + ) : ( +
+ Select a task to view its subtasks +
+ )} +
+
+ ); +} diff --git a/src/app/iq/SubtaskDetails.tsx b/src/app/iq/SubtaskDetails.tsx new file mode 100644 index 0000000..5d1e381 --- /dev/null +++ b/src/app/iq/SubtaskDetails.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState } from "react"; +import { SubTask, Task } from "./IQDashboard"; +import { Badge } from "@/app/shadcn_components/ui/badge"; +import { ScrollArea } from "@/app/shadcn_components/ui/scroll-area"; +import { Card } from "@/app/shadcn_components/ui/card"; +import { Button } from "@/app/shadcn_components/ui/button"; + +interface SubtaskDetailsProps { + selectedTaskId: string; + subtasks: SubTask[]; + task?: Task; +} + +function getStatusColor( + status: string +): "default" | "secondary" | "destructive" | "outline" { + switch (status.toLowerCase()) { + case "completed": + return "default"; + case "in progress": + return "secondary"; + case "failed": + return "destructive"; + default: + return "outline"; + } +} + +function formatJson(jsonString: string | null): string { + if (!jsonString) return "N/A"; + try { + return JSON.stringify(JSON.parse(jsonString), null, 2); + } catch { + return jsonString; + } +} + +function CopyableCodeBlock({ + content, + label, +}: { + content: string; + label: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + return ( +
+
+

{label}

+ +
+
+        {content}
+      
+
+ ); +} + +export default function SubtaskDetails({ + selectedTaskId, + subtasks, + task, +}: SubtaskDetailsProps) { + return ( +
+ {/* Task Header */} + {task && ( +
+
+
+

+ {task.taskSource} +

+ + {task.status} + +
+
+
+

Task ID

+ + {task.id} + +
+ {task.service && ( +
+

Service

+

{task.service}

+
+ )} + {task.jobStarted && ( +
+

Job Started

+

+ {new Date(task.jobStarted).toLocaleString()} +

+
+ )} + {task.jobCompleted && ( +
+

Job Completed

+

+ {new Date(task.jobCompleted).toLocaleString()} +

+
+ )} +
+

Updated

+

+ {new Date(task.updatedAt).toLocaleString()} +

+
+
+
+
+ )} + + {/* Subtasks List */} + +
+
+

+ Subtasks ({subtasks.length}) +

+ + {subtasks.length === 0 && ( +
+ No subtasks found +
+ )} + +
+ {subtasks.map((subtask, index) => ( + +
+ {/* Header */} +
+
+

+ Subtask {index + 1} +

+ + {subtask.id} + +
+ + {subtask.status} + +
+ + {/* Timeline */} +
+ {subtask.jobStarted && ( +
+

Started

+

+ {new Date(subtask.jobStarted).toLocaleString()} +

+
+ )} + {subtask.jobCompleted && ( +
+

Completed

+

+ {new Date(subtask.jobCompleted).toLocaleString()} +

+
+ )} +
+ + {/* Inputs */} + {subtask.inputs && ( + + )} + + {/* Outputs */} + {subtask.outputs && ( + + )} + + {/* Cloud Logs */} + {subtask.cloudLogsURL && ( +
+

+ Cloud Logs +

+ + {subtask.cloudLogsURL} + +
+ )} + + {/* Updated */} +

+ Updated: {new Date(subtask.updatedAt).toLocaleString()} +

+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/app/iq/TaskList.tsx b/src/app/iq/TaskList.tsx new file mode 100644 index 0000000..f87539a --- /dev/null +++ b/src/app/iq/TaskList.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Task } from "./IQDashboard"; +import { Badge } from "@/app/shadcn_components/ui/badge"; +import { ScrollArea } from "@/app/shadcn_components/ui/scroll-area"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/app/shadcn_components/ui/select"; + +interface TaskListProps { + tasks: Task[]; + selectedTaskId: string | null; + onSelectTask: (taskId: string) => void; + loading: boolean; + loadingMore: boolean; + error: string | null; + total: number; + onLoadMore: () => void; + onRefresh: () => void; +} + +type SortOption = "recent" | "oldest" | "status" | "service"; + +function getStatusColor( + status: string +): "default" | "secondary" | "destructive" | "outline" { + switch (status.toLowerCase()) { + case "completed": + case "in progress": + return "default"; + case "failed": + return "destructive"; + default: + return "secondary"; + } +} + +export default function TaskList({ + tasks, + selectedTaskId, + onSelectTask, + loading, + loadingMore, + error, + total, + onLoadMore, + onRefresh, +}: TaskListProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [serviceFilter, setServiceFilter] = useState("all"); + const [sortBy, setSortBy] = useState("recent"); + + // Get unique statuses and services for filter options + const uniqueStatuses = useMemo( + () => [...new Set(tasks.map((t) => t.status))].sort(), + [tasks] + ); + const uniqueServices = useMemo( + () => + [...new Set(tasks.map((t) => t.service).filter(Boolean))].sort() as string[], + [tasks] + ); + + // Filter and sort tasks + const filteredTasks = useMemo(() => { + let result = tasks; + + // Status filter + if (statusFilter !== "all") { + result = result.filter((t) => t.status === statusFilter); + } + + // Service filter + if (serviceFilter !== "all") { + result = result.filter((t) => t.service === serviceFilter); + } + + // Search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (t) => + t.id.toLowerCase().includes(query) || + t.taskSource.toLowerCase().includes(query) || + (t.service?.toLowerCase().includes(query) ?? false) + ); + } + + // Sort + const sorted = [...result]; + switch (sortBy) { + case "recent": + sorted.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + break; + case "oldest": + sorted.sort( + (a, b) => + new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + ); + break; + case "status": + sorted.sort((a, b) => a.status.localeCompare(b.status)); + break; + case "service": + sorted.sort((a, b) => + (a.service ?? "").localeCompare(b.service ?? "") + ); + break; + } + + return sorted; + }, [tasks, statusFilter, serviceFilter, searchQuery, sortBy]); + + return ( +
+ {/* Header */} +
+
+
+

Tasks

+

+ {filteredTasks.length} of {tasks.length} (Total: {total}) +

+
+ +
+
+ + {/* Filters */} +
+ {/* Search */} + setSearchQuery(e.target.value)} + className="text-sm" + /> + + {/* Sort */} + + + {/* Status Filter */} + {uniqueStatuses.length > 0 && ( + + )} + + {/* Service Filter */} + {uniqueServices.length > 0 && ( + + )} + + {/* Reset Filters */} + {(searchQuery || statusFilter !== "all" || serviceFilter !== "all") && ( + + )} +
+ + {/* Content */} + + {error && ( +
+ {error} +
+ )} + + {loading && ( +
+ Loading tasks... +
+ )} + + {!loading && !error && tasks.length === 0 && ( +
+ No tasks found +
+ )} + +
+ {filteredTasks.map((task) => ( + + ))} + + {/* Load More Button */} + {tasks.length < total && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/app/iq/page.tsx b/src/app/iq/page.tsx new file mode 100644 index 0000000..4410678 --- /dev/null +++ b/src/app/iq/page.tsx @@ -0,0 +1,5 @@ +import IQDashboard from "./IQDashboard"; + +export default function IQPage() { + return ; +}