Add a Re-run button to the run detail page
Re-run takes you to the script's form pre-filled with that run's variable values, rather than re-executing immediately - this is necessary, not just cautious: stored run.variables already have secret fields redacted to "***" (see the run detail command/variables view), so a true one-click re-run would either fail validation or, worse, silently pass the literal string "***" to the script as a real secret. Pre-filling instead lets the user review/tweak values and forces secret fields to be re-entered, both of which are new safe defaults now that required fields are actually enforced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,23 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { RotateCw } from "lucide-react";
|
||||||
import { getDb } from "@/lib/db/client";
|
import { getDb } from "@/lib/db/client";
|
||||||
import { runs } from "@/lib/db/schema";
|
import { runs } from "@/lib/db/schema";
|
||||||
import { getScript } from "@/lib/config/load";
|
import { getScript } from "@/lib/config/load";
|
||||||
import { readLogTail } from "@/lib/runner/log-file";
|
import { readLogTail } from "@/lib/runner/log-file";
|
||||||
import { RunTerminal } from "@/components/runs/run-terminal";
|
import { RunTerminal } from "@/components/runs/run-terminal";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
interface RunDetailPageProps {
|
interface RunDetailPageProps {
|
||||||
params: Promise<{ runId: string }>;
|
params: Promise<{ runId: string }>;
|
||||||
@@ -61,6 +70,17 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{run.scriptName}</CardTitle>
|
<CardTitle>{run.scriptName}</CardTitle>
|
||||||
|
{script && (
|
||||||
|
<CardAction>
|
||||||
|
<Link
|
||||||
|
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
<RotateCw className="size-3.5" />
|
||||||
|
Re-run
|
||||||
|
</Link>
|
||||||
|
</CardAction>
|
||||||
|
)}
|
||||||
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3">
|
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium">Triggered by</dt>
|
<dt className="font-medium">Triggered by</dt>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getScript } from "@/lib/config/load";
|
import { getScript } from "@/lib/config/load";
|
||||||
import { serializeScriptForClient } from "@/lib/config/serialize";
|
import { serializeScriptForClient } from "@/lib/config/serialize";
|
||||||
|
import { getDb } from "@/lib/db/client";
|
||||||
|
import { runs } from "@/lib/db/schema";
|
||||||
import { DynamicForm } from "@/components/forms/dynamic-form";
|
import { DynamicForm } from "@/components/forms/dynamic-form";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
|
|
||||||
interface ScriptPageProps {
|
interface ScriptPageProps {
|
||||||
params: Promise<{ scriptId: string }>;
|
params: Promise<{ scriptId: string }>;
|
||||||
|
searchParams: Promise<{ fromRun?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
@@ -25,13 +29,31 @@ export async function generateMetadata({
|
|||||||
return { title: script?.name ?? "Script not found" };
|
return { title: script?.name ?? "Script not found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ScriptPage({ params }: ScriptPageProps) {
|
export default async function ScriptPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: ScriptPageProps) {
|
||||||
const { scriptId } = await params;
|
const { scriptId } = await params;
|
||||||
|
const { fromRun } = await searchParams;
|
||||||
const script = getScript(scriptId);
|
const script = getScript(scriptId);
|
||||||
if (!script) notFound();
|
if (!script) notFound();
|
||||||
|
|
||||||
const clientScript = serializeScriptForClient(script);
|
const clientScript = serializeScriptForClient(script);
|
||||||
|
|
||||||
|
// Only trust a previous run's variables as prefill if it's actually a run of this same
|
||||||
|
// script - a `fromRun` id for a different script's run wouldn't line up with these variables.
|
||||||
|
let initialValues: Record<string, unknown> | undefined;
|
||||||
|
if (fromRun) {
|
||||||
|
const previousRun = getDb()
|
||||||
|
.select({ scriptId: runs.scriptId, variables: runs.variables })
|
||||||
|
.from(runs)
|
||||||
|
.where(eq(runs.id, fromRun))
|
||||||
|
.get();
|
||||||
|
if (previousRun?.scriptId === scriptId) {
|
||||||
|
initialValues = previousRun.variables;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -42,7 +64,7 @@ export default async function ScriptPage({ params }: ScriptPageProps) {
|
|||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DynamicForm script={clientScript} />
|
<DynamicForm script={clientScript} initialValues={initialValues} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Play, Loader2 } from "lucide-react";
|
import { Play, Loader2, Info } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form } from "@/components/ui/form";
|
import { Form } from "@/components/ui/form";
|
||||||
@@ -19,22 +19,40 @@ function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultValuesFor(script: ClientScript): Record<string, unknown> {
|
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
|
||||||
|
* secret fields are deliberately excluded there (their stored value is just "***", not the real
|
||||||
|
* one), so those always fall through to the normal empty/default state and have to be re-entered. */
|
||||||
|
function defaultValuesFor(
|
||||||
|
script: ClientScript,
|
||||||
|
initialValues?: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
const values: Record<string, unknown> = {};
|
const values: Record<string, unknown> = {};
|
||||||
for (const variable of script.variables) {
|
for (const variable of script.variables) {
|
||||||
values[variable.name] = variable.default ?? emptyValueFor(variable);
|
const fromPreviousRun =
|
||||||
|
initialValues && !variable.secret
|
||||||
|
? initialValues[variable.name]
|
||||||
|
: undefined;
|
||||||
|
values[variable.name] =
|
||||||
|
fromPreviousRun ?? variable.default ?? emptyValueFor(variable);
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DynamicForm({ script }: { script: ClientScript }) {
|
export function DynamicForm({
|
||||||
|
script,
|
||||||
|
initialValues,
|
||||||
|
}: {
|
||||||
|
script: ClientScript;
|
||||||
|
initialValues?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const schema = buildVariableSchemaFromList(script.variables);
|
const schema = buildVariableSchemaFromList(script.variables);
|
||||||
|
const hasSecretVariable = script.variables.some((v) => v.secret);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaultValuesFor(script),
|
defaultValues: defaultValuesFor(script, initialValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: Record<string, unknown>) {
|
async function onSubmit(values: Record<string, unknown>) {
|
||||||
@@ -67,6 +85,16 @@ export function DynamicForm({ script }: { script: ClientScript }) {
|
|||||||
<AlertDescription>{submitError}</AlertDescription>
|
<AlertDescription>{submitError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{initialValues && (
|
||||||
|
<Alert>
|
||||||
|
<Info />
|
||||||
|
<AlertDescription>
|
||||||
|
Pre-filled from a previous run.
|
||||||
|
{hasSecretVariable &&
|
||||||
|
" Secret fields aren't carried over and need to be entered again."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{script.variables.length === 0 && (
|
{script.variables.length === 0 && (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
This script takes no parameters.
|
This script takes no parameters.
|
||||||
|
|||||||
Reference in New Issue
Block a user