From 081d504db086762588e9d14bb74a2b90ef4a0bae Mon Sep 17 00:00:00 2001 From: Ashwin V C Date: Fri, 31 Jan 2025 18:11:02 +0530 Subject: [PATCH] feat: smoke test trigger on edit, ui updates for validation status page (#135) * feat: trigger smoke test on edit, fix maxDuration * update maxduration * update ui * fix ui state management, redirect to edit on fail --- apps/app/app/api/pipelines/edit.ts | 44 ++- apps/app/app/api/streams/[id]/sse/route.ts | 1 + apps/app/app/pipelines/[id]/page.tsx | 7 +- .../app/components/pipeline/edit-pipeline.tsx | 6 +- apps/app/components/pipeline/status.tsx | 81 ---- .../components/pipeline/validate-pipeline.tsx | 361 +++++++++++++++++- packages/design-system/components/ui/card.tsx | 2 +- 7 files changed, 397 insertions(+), 105 deletions(-) delete mode 100644 apps/app/components/pipeline/status.tsx diff --git a/apps/app/app/api/pipelines/edit.ts b/apps/app/app/api/pipelines/edit.ts index 4cf6323a..afaecca5 100644 --- a/apps/app/app/api/pipelines/edit.ts +++ b/apps/app/app/api/pipelines/edit.ts @@ -4,6 +4,27 @@ import { uploadFile } from "@/app/api/pipelines/storage"; import { pipelineSchema } from "@/lib/types"; import { createServerClient } from "@repo/supabase/server"; import { z } from "zod"; +import { triggerSmokeTest } from "./validation"; +import { createSmokeTestStream } from "./validation"; + +export async function publishPipeline(pipelineId: string, userId: string) { + const supabase = await createServerClient(); + + await validateUser(userId); + + const { data: updatedPipeline, error: updateError } = await supabase + .from("pipelines") + .upsert({ + id: pipelineId, + is_private: false, + }) + .select() + .single(); + + if (updateError) throw new Error(updateError.message); + + return { pipeline: updatedPipeline }; +} export async function updatePipeline(body: any, userId: string) { const supabase = await createServerClient(); @@ -15,16 +36,22 @@ export async function updatePipeline(body: any, userId: string) { if (!validationResult.success) { throw new z.ZodError(validationResult.error.errors); } - - const { data, error } = await supabase + const pipelineId = body.id; + const { data: pipeline, error } = await supabase .from("pipelines") .update(validationResult.data) - .eq("id", body.id) + .eq("id", pipelineId) .eq("author", userId) - .select(); + .select() + .single(); if (error) throw new Error(error.message); - return data; + + // Create a smoke test stream using the pipeline for pre-publication validation with new config saved + const smokeTestStream = await createSmokeTestStream(pipelineId); + await triggerSmokeTest(smokeTestStream.stream_key); + + return { pipeline, smokeTestStream }; } export async function editPipelineFromFormData( @@ -56,6 +83,9 @@ export async function editPipelineFromFormData( config: comfyUiJson, }; - const pipeline = await updatePipeline(pipelineData, userId); - return pipeline; + const { pipeline, smokeTestStream } = await updatePipeline( + pipelineData, + userId + ); + return { pipeline, smokeTestStream }; } diff --git a/apps/app/app/api/streams/[id]/sse/route.ts b/apps/app/app/api/streams/[id]/sse/route.ts index 18d8217d..95bc0031 100644 --- a/apps/app/app/api/streams/[id]/sse/route.ts +++ b/apps/app/app/api/streams/[id]/sse/route.ts @@ -2,6 +2,7 @@ import { getStoredStreamStatus } from "@/app/api/pipelines/validation"; // Prevents this route's response from being cached on Vercel export const dynamic = "force-dynamic"; +export const maxDuration = 300; export async function GET( request: Request, diff --git a/apps/app/app/pipelines/[id]/page.tsx b/apps/app/app/pipelines/[id]/page.tsx index 865ca12c..10285a99 100644 --- a/apps/app/app/pipelines/[id]/page.tsx +++ b/apps/app/app/pipelines/[id]/page.tsx @@ -19,7 +19,12 @@ export default async function Page({ } if (isValidationMode) { - return ; + return ( + + ); } const pipeline = await getPipeline(pipelineId); diff --git a/apps/app/components/pipeline/edit-pipeline.tsx b/apps/app/components/pipeline/edit-pipeline.tsx index 26b91030..71e8d5fa 100644 --- a/apps/app/components/pipeline/edit-pipeline.tsx +++ b/apps/app/components/pipeline/edit-pipeline.tsx @@ -14,13 +14,14 @@ import { Loader2, LoaderCircleIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import FileUploadDropzone, { FileType } from "./json-upload"; -import PipelineStatus from "./status"; +import { useRouter } from "next/navigation"; export default function EditPipeline({ pipeline, }: { pipeline: PipelineSchema & { id: string }; }) { + const router = useRouter(); const { authenticated, user, ready: isAuthLoaded } = usePrivy(); const [formData, setFormData] = useState>(() => { return { @@ -56,9 +57,10 @@ export default function EditPipeline({ formDataToSend.append(decamelize(key), value as any); }); console.log("Form data to send::", formDataToSend, formData); - const pipeline = await editPipelineFromFormData(formDataToSend, user.id); + const { pipeline, smokeTestStream } = await editPipelineFromFormData(formDataToSend, user.id); toast.dismiss(toastId); toast.success("Pipeline saved successfully"); + router.push(`/pipelines/${pipeline.id}?streamId=${smokeTestStream.id}&validation=true`); } catch (error) { toast.dismiss(toastId); const errorMessage = diff --git a/apps/app/components/pipeline/status.tsx b/apps/app/components/pipeline/status.tsx deleted file mode 100644 index 7258548e..00000000 --- a/apps/app/components/pipeline/status.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import { Label } from "@repo/design-system/components/ui/label"; -import { cn } from "@repo/design-system/lib/utils"; -import { useEffect, useState } from "react"; - -export default function PipelineStatus({ - streamId, - setIsLoading, -}: { - streamId: string; - setIsLoading: (isLoading: boolean) => void; -}) { - const [data, setData] = useState(null); - - useEffect(() => { - let source: EventSource; - const fetchSseMessages = async () => { - source = new EventSource(`/api/streams/${streamId}/sse`); - source.onmessage = (event) => { - const data = JSON.parse(event.data); - setData(data); - }; - source.onerror = (error) => { - console.error("Error fetching pipeline status:", error); - source.close(); - }; - setTimeout(() => { - if (source) { - source.close(); - } - setIsLoading(false); - }, 60000); - }; - fetchSseMessages(); - - return () => { - if (source) { - source.close(); - } - }; - }, []); - - return ( -
-
- -
-
-

- {data?.state || "PROCESSING"} -

-
-
-
- -
-

- {data?.inference_status?.fps?.toFixed(2) || "PROCESSING"} -

-
-
- - {data?.inference_status?.last_error ? ( -
- -
-

- {data?.inference_status?.last_error} -

-
-
- ) : null} -
- ); -} diff --git a/apps/app/components/pipeline/validate-pipeline.tsx b/apps/app/components/pipeline/validate-pipeline.tsx index a44c5524..ab6edc6a 100644 --- a/apps/app/components/pipeline/validate-pipeline.tsx +++ b/apps/app/components/pipeline/validate-pipeline.tsx @@ -1,21 +1,356 @@ "use client"; -import { LoaderCircleIcon } from "lucide-react"; -import PipelineStatus from "./status"; -import { useState } from "react"; +import { publishPipeline } from "@/app/api/pipelines/edit"; +import { usePrivy } from "@privy-io/react-auth"; +import { Button } from "@repo/design-system/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@repo/design-system/components/ui/card"; +import { Separator } from "@repo/design-system/components/ui/separator"; +import { AlertCircle, CircleDot, Info, LoaderCircle, Zap } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; -export default function ValidatePipeline({ streamId }: { streamId: string }) { +const TIMEOUT_MS = 90000; + +function usePipelineStatus(streamId: string) { + const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); - return ( -
-
-

- Pre-Deployment Pipeline Validation -

- {isLoading && } -
+ useEffect(() => { + let source: EventSource; + const fetchSseMessages = async () => { + source = new EventSource(`/api/streams/${streamId}/sse`); + source.onmessage = (event) => { + const data = JSON.parse(event.data); + setData(data); + }; + source.onerror = (error) => { + console.error("Error fetching pipeline status:", error); + source.close(); + }; + setTimeout(() => { + if (source) { + source.close(); + } + setIsLoading(false); + }, TIMEOUT_MS); + }; + fetchSseMessages(); + + return () => { + if (source) { + source.close(); + } + }; + }, []); + + return { data, isLoading }; +} + +function validateFps(fps: number) { + if (fps < 5) { + return { + variant: "error", + text: `${fps} FPS - further optimization required (cannot be published)`, + }; + } + if (fps < 20) { + return { + variant: "warning", + text: `${fps} FPS - recommended further optimization`, + }; + } + + return { + variant: "success", + text: `${fps} FPS`, + }; +} + +function validateDegradation(degradation: number) { + if (degradation > 30) { + return { + variant: "error", + text: `${degradation.toFixed(2)}% - further optimization required (cannot be published)`, + }; + } + if (degradation > 10) { + return { + variant: "warning", + text: `${degradation.toFixed(2)}% - recommended further optimization`, + }; + } + + return { + variant: "success", + text: `${degradation.toFixed(2)}%`, + }; +} + +function getPipelineStatus(data: any) { + const state = (data?.state || "initiated").toLowerCase(); + const inputFps = data?.input_status?.fps?.toFixed(2) ?? 0; + const outputFps = data?.inference_status?.fps?.toFixed(2) ?? 0; + const error = data?.inference_status?.last_error; + const degradation = + inputFps > 0 ? ((inputFps - outputFps) / inputFps) * 100 : 0; + + switch (state) { + case "initiated": + return { + status: { + variant: "info", + text: "Initiated", + icon: , + }, + outputFps: { + variant: "info", + text: "Initiated", + icon: , + }, + + degradation: { + variant: "info", + text: "Initiated", + icon: , + }, + error: error + ? { + variant: "error", + text: error, + icon: , + } + : null, + }; + case "online": + return { + status: { + variant: "success", + text: "Online", + icon: , + }, + outputFps: { + ...validateFps(outputFps), + icon: , + }, + + degradation: { + ...validateDegradation(degradation), + icon: , + }, + error: error + ? { + variant: "error", + text: error, + icon: , + } + : null, + }; + default: + return { + status: { + variant: "warning", + text: "Processing", + icon: , + }, + outputFps: { + ...validateFps(outputFps), + icon: , + }, - + degradation: { + ...validateDegradation(degradation), + icon: , + }, + error: error + ? { + variant: "error", + text: error, + icon: , + } + : null, + }; + } +} + +type StatusVariant = "success" | "warning" | "error" | "info"; + +function StatusTile({ + text, + icon, + variant, +}: { + text: string; + icon: React.ReactNode; + variant: StatusVariant; +}) { + const variantColorWrapper = { + wrapper: { + success: + "bg-emerald-50 dark:bg-emerald-950/50 border-emerald-200 dark:border-emerald-800/50", + warning: + "bg-amber-50 dark:bg-amber-950/50 border-amber-200 dark:border-amber-800/50", + error: + "bg-red-50 dark:bg-red-950/50 border-red-200 dark:border-red-800/50", + info: "bg-blue-50 dark:bg-blue-950/50 border-blue-200 dark:border-blue-800/50", + }, + icon: { + success: "text-emerald-500 dark:text-emerald-400", + warning: "text-amber-500 dark:text-amber-400", + error: "text-red-500 dark:text-red-400", + info: "text-blue-500 dark:text-blue-400", + }, + text: { + success: "text-emerald-700 dark:text-emerald-200", + warning: "text-amber-700 dark:text-amber-200", + error: "text-red-700 dark:text-red-200", + info: "text-blue-700 dark:text-blue-200", + }, + }; + + return ( +
+ {icon} + {text}
); } + +export default function ValidatePipeline({ + pipelineId, + streamId, +}: { + pipelineId: string; + streamId: string; +}) { + const router = useRouter(); + const { authenticated, user, ready: isAuthLoaded } = usePrivy(); + const { data, isLoading } = usePipelineStatus(streamId); + const { status, degradation, outputFps, error } = getPipelineStatus(data); + const isPublishable = + degradation.variant !== "error" && outputFps.variant !== "error" && !error; + + return ( +
+
+
+

+ Validate Pipeline +

+

+ Configure and publish your streaming pipeline +

+
+ + + + + Review pre-deployment test results + {isLoading && } + +

+ {isLoading + ? ` We are running your pipeline to benchmark its performance. This may take upto 90 seconds` + : `We ran your pipeline for 90 seconds to benchmark its performance.`} +

+
+ + {/* Framerate Section */} +
+

+ Average Framerate +

+ +
+ + {/* Degradation Section */} +
+

+ Framerate Degradation +

+ +
+ + {/* Parameter Control - Error State */} +
+

Status

+ +
+ + {/* Error Section */} + {error && ( +
+

Error

+ +
+ )} +
+ + + {isLoading || isPublishable ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/packages/design-system/components/ui/card.tsx b/packages/design-system/components/ui/card.tsx index e4acb5d5..9ee3732a 100644 --- a/packages/design-system/components/ui/card.tsx +++ b/packages/design-system/components/ui/card.tsx @@ -8,7 +8,7 @@ const Card = React.forwardRef< >(({ className, ...props }, ref) => (
));