Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions client/src/components/app/jobs/JobOutputModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useEffect, useRef, type ReactNode } from "react";

interface JobOutputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
logs: string[];
isRunning: boolean;
renderLine?: (line: string) => ReactNode;
}

export default function JobOutputModal({
open,
onOpenChange,
logs,
isRunning,
renderLine,
}: JobOutputModalProps) {
const outputRef = useRef<HTMLDivElement>(null);
const logText = logs.join("\n");

useEffect(() => {
if (!open || !isRunning || !outputRef.current) {
return;
}
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}, [open, isRunning, logText]);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[66vw] w-11/12 max-h-[70vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Job output</DialogTitle>
<DialogDescription>
Public output from the running job.
</DialogDescription>
</DialogHeader>
<div
ref={outputRef}
className="mt-4 flex-1 min-h-[240px] max-h-[50vh] overflow-y-auto rounded-md border bg-muted/40 p-4"
>
{logs.length === 0 ? (
<p className="font-serif text-sm text-muted-foreground">
Waiting for output…
</p>
) : (
<div className="font-serif text-sm space-y-1">
{logs.map((line, index) => (
<p key={index} className="whitespace-pre-wrap">
{renderLine ? renderLine(line) : line}
</p>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
86 changes: 86 additions & 0 deletions client/src/components/app/jobs/useJobWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { trpc } from "@/utils";
import { useEffect, useRef, useState } from "react";

const TERMINAL_JOB_STATES = new Set(["completed", "failed"]);

export function isTerminalJobState(state: string | undefined): boolean {
return !!state && TERMINAL_JOB_STATES.has(state);
}

/**
* Watches a job identified by `watchKey` (queue name and job id) using polling.
*
* @param watchKey Queue and job id, or null/undefined to stop watching.
*/
export function useJobWatcher(watchKey: string | null | undefined) {
const utils = trpc.useContext();
const [logs, setLogs] = useState<string[]>([]);
const consumedUpdatedAt = useRef(0);

useEffect(() => {
setLogs([]);
consumedUpdatedAt.current = 0;
}, [watchKey]);

const statusQuery = trpc.jobWatching.status.useQuery(
{ watchKey: watchKey ?? "" },
{
enabled: !!watchKey,
refetchInterval: (data) =>
isTerminalJobState(data?.state) ? false : 2000,
retry: false,
}
);

const isTerminal = isTerminalJobState(statusQuery.data?.state);

const logsQuery = trpc.jobWatching.logs.useQuery(
{ watchKey: watchKey ?? "", startLineIndex: logs.length },
{
enabled: !!watchKey && !statusQuery.isError,
refetchInterval: isTerminal || statusQuery.isError ? false : 2000,
retry: false,
}
);

useEffect(() => {
if (!watchKey || !logsQuery.data) {
return;
}
if (logsQuery.dataUpdatedAt === consumedUpdatedAt.current) {
return;
}
consumedUpdatedAt.current = logsQuery.dataUpdatedAt;
if (logsQuery.data.logs.length === 0) {
return;
}
setLogs((prev) => [...prev, ...logsQuery.data.logs]);
}, [watchKey, logsQuery.data, logsQuery.dataUpdatedAt]);

const refetchLogs = logsQuery.refetch;
useEffect(() => {
if (!isTerminal || !watchKey) {
return;
}
void refetchLogs();
}, [isTerminal, watchKey, refetchLogs]);

return {
watchKey: watchKey ?? null,
status: statusQuery.data ?? null,
logs,
logText: logs.join("\n"),
isTerminal,
startedAt: statusQuery.data?.startedAt ?? null,
isError: statusQuery.isError || logsQuery.isError,
resetLogs: () => {
setLogs([]);
consumedUpdatedAt.current = 0;
if (!watchKey) {
return;
}
void utils.jobWatching.logs.invalidate();
void utils.jobWatching.status.invalidate({ watchKey });
},
};
}
246 changes: 246 additions & 0 deletions client/src/components/app/recipes/RecipeJobStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import JobOutputModal from "@/components/app/jobs/JobOutputModal";
import { useJobWatcher } from "@/components/app/jobs/useJobWatcher";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { useToast } from "@/components/ui/use-toast";
import { TimeElapsedText } from "@/useTimeElapsed";
import { RecipeDetectionStatus, trpc } from "@/utils";
import { Check, LoaderIcon, RotateCcw, ScrollText } from "lucide-react";
import { useState } from "react";

const STATUS_PREFIX = "<status>";
const STATUS_SUFFIX = "</status>";

function parseStatusLogMessage(line: string): string | null {
if (!line.startsWith(STATUS_PREFIX)) {
return null;
}
let message = line.slice(STATUS_PREFIX.length);
if (message.endsWith(STATUS_SUFFIX)) {
message = message.slice(0, -STATUS_SUFFIX.length);
}
message = message.trim();
return message || null;
}

function renderRecipeJobLogLine(line: string) {
const statusMessage = parseStatusLogMessage(line);
if (statusMessage) {
return (
<>
<em>Status changed:</em> {statusMessage}
</>
);
}
return line;
}

function latestStatusFromLogs(logs: string[]): string | null {
for (let i = logs.length - 1; i >= 0; i--) {
const statusMessage = parseStatusLogMessage(logs[i]);
if (statusMessage) {
return statusMessage;
}
}
return null;
}

export default function RecipeJobStatus({ recipeId }: { recipeId: number }) {
const [jobOutputOpen, setJobOutputOpen] = useState(false);
const { toast } = useToast();
const recipeQuery = trpc.recipes.detail.useQuery(
{ id: recipeId },
{
refetchInterval: (data) =>
data?.status === RecipeDetectionStatus.SUCCESS ? false : 2000,
}
);
const recipeStatus = recipeQuery.data?.status;
const jobStatusQuery = trpc.recipes.configurationJobStatus.useQuery(
{ recipeId },
{
refetchInterval: (data) => {
if (data?.watchKey) {
return false;
}
if (
recipeStatus === RecipeDetectionStatus.WAITING ||
recipeStatus === RecipeDetectionStatus.IN_PROGRESS
) {
return 2000;
}
return false;
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}
);
const jobWatcher = useJobWatcher(jobStatusQuery.data?.watchKey ?? null);
const reconfigureRecipe = trpc.recipes.reconfigure.useMutation();
const reconfigureAgenticRecipe = trpc.recipes.reconfigureAgentic.useMutation();

const recipe = recipeQuery.data;
const isAgenticJob = jobStatusQuery.data?.kind === "agentic";
if (!recipe || (recipe.status === RecipeDetectionStatus.SUCCESS && !isAgenticJob)) {
return null;
}

const isAgenticComplete =
isAgenticJob && recipe.status === RecipeDetectionStatus.SUCCESS;
const showOutputButton =
!isAgenticComplete || jobWatcher.logs.length > 0;
const lastStatus = latestStatusFromLogs(jobWatcher.logs);
const elapsedTickMs = jobWatcher.isTerminal ? 0 : 1000;
const elapsed = (
<TimeElapsedText
startTimestamp={jobWatcher.startedAt}
tickIntervalMs={elapsedTickMs}
/>
);

async function onReconfigure() {
if (!recipe) {
return;
}
try {
if (isAgenticJob) {
await reconfigureAgenticRecipe.mutateAsync({ id: recipe.id });
} else {
await reconfigureRecipe.mutateAsync({ id: recipe.id });
}
jobWatcher.resetLogs();
await recipeQuery.refetch();
} catch (err) {
toast({
title: isAgenticJob
? "Could not retry agentic configuration"
: "Could not redetect configuration",
description: (err as Error).message,
variant: "destructive",
});
}
}

return (
<>
{isAgenticJob &&
recipe.status !== RecipeDetectionStatus.ERROR ? (
<div className="mt-4 grid gap-2 md:grid-cols-[1fr_250px] lg:grid-cols-2 lg:gap-4">
<Card>
<CardHeader>
<CardDescription>Agentic Configuration</CardDescription>
</CardHeader>
<CardContent className="text-sm space-y-2">
{isAgenticComplete ? (
<div className="flex items-center">
<Check className="mr-2 w-4 h-4" />
<span>Agent finished configuring this recipe.</span>
</div>
) : (
<div className="flex items-center">
<LoaderIcon className="animate-spin mr-2 w-4 h-4" />
<span>Agent is configuring this recipe…</span>
</div>
)}
{lastStatus ? (
<p className="font-serif text-muted-foreground">
{lastStatus}
{jobWatcher.startedAt ? <> · {elapsed}</> : null}
</p>
) : jobWatcher.startedAt && !isAgenticComplete ? (
<p className="text-muted-foreground">Running for {elapsed}</p>
) : null}
{showOutputButton ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setJobOutputOpen(true)}
>
<ScrollText className="w-4 h-4 mr-2" />
View output
</Button>
) : null}
</CardContent>
</Card>
</div>
) : null}
{recipe.status == RecipeDetectionStatus.WAITING && !isAgenticJob ? (
<div className="mt-4 grid gap-2 md:grid-cols-[1fr_250px] lg:grid-cols-2 lg:gap-4">
<Card>
<CardHeader>
<CardDescription>Configuration Pending</CardDescription>
</CardHeader>
<CardContent className="text-sm">
<p>
Configuration detection hasn't started for this recipe. Please
check back later.
</p>
</CardContent>
</Card>
</div>
) : null}
{recipe.status == RecipeDetectionStatus.ERROR ? (
<div className="mt-4 grid gap-2 md:grid-cols-[1fr_250px] lg:grid-cols-2 lg:gap-4">
<Card>
<CardHeader>
<CardDescription>Configuration Error</CardDescription>
</CardHeader>
<CardContent className="text-sm">
<p className="text-red-800 font-semibold">
{isAgenticJob
? "The agentic configuration workflow failed for this recipe."
: "CTDL xTRA failed to detect a valid configuration for this recipe."}
</p>
<p className="mt-4">You can adjust the URL, or try again.</p>
<p className="mt-8">Failure reason:</p>
<pre className="mt-2 text-xs overflow-x-auto">
{recipe.detectionFailureReason}
</pre>
{jobWatcher.logs.length > 0 ? (
<Button
type="button"
className="mt-4"
variant="outline"
size="sm"
onClick={() => setJobOutputOpen(true)}
>
<ScrollText className="w-4 h-4 mr-2" />
View output
</Button>
) : null}
<Button
type="button"
className="mt-8"
variant="outline"
size="sm"
onClick={onReconfigure}
disabled={
reconfigureRecipe.isLoading ||
reconfigureAgenticRecipe.isLoading
}
>
<RotateCcw className="w-4 h-4 mr-2" />
{isAgenticJob
? "Retry agentic configuration"
: "Redetect configuration"}
</Button>
</CardContent>
</Card>
</div>
) : null}
<JobOutputModal
open={jobOutputOpen && showOutputButton}
onOpenChange={setJobOutputOpen}
logs={jobWatcher.logs}
isRunning={!jobWatcher.isTerminal}
renderLine={renderRecipeJobLogLine}
/>
</>
);
}
Loading
Loading