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";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { runs } from "@/lib/db/schema";
|
||||
import { getScript } from "@/lib/config/load";
|
||||
import { readLogTail } from "@/lib/runner/log-file";
|
||||
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 {
|
||||
params: Promise<{ runId: string }>;
|
||||
@@ -61,6 +70,17 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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">
|
||||
<div>
|
||||
<dt className="font-medium">Triggered by</dt>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getScript } from "@/lib/config/load";
|
||||
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 {
|
||||
Card,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
|
||||
interface ScriptPageProps {
|
||||
params: Promise<{ scriptId: string }>;
|
||||
searchParams: Promise<{ fromRun?: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -25,13 +29,31 @@ export async function generateMetadata({
|
||||
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 { fromRun } = await searchParams;
|
||||
const script = getScript(scriptId);
|
||||
if (!script) notFound();
|
||||
|
||||
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 (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card>
|
||||
@@ -42,7 +64,7 @@ export default async function ScriptPage({ params }: ScriptPageProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicForm script={clientScript} />
|
||||
<DynamicForm script={clientScript} initialValues={initialValues} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Play, Loader2 } from "lucide-react";
|
||||
import { Play, Loader2, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
@@ -19,22 +19,40 @@ function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
|
||||
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> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
export function DynamicForm({ script }: { script: ClientScript }) {
|
||||
export function DynamicForm({
|
||||
script,
|
||||
initialValues,
|
||||
}: {
|
||||
script: ClientScript;
|
||||
initialValues?: Record<string, unknown>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const schema = buildVariableSchemaFromList(script.variables);
|
||||
const hasSecretVariable = script.variables.some((v) => v.secret);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaultValuesFor(script),
|
||||
defaultValues: defaultValuesFor(script, initialValues),
|
||||
});
|
||||
|
||||
async function onSubmit(values: Record<string, unknown>) {
|
||||
@@ -67,6 +85,16 @@ export function DynamicForm({ script }: { script: ClientScript }) {
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</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 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This script takes no parameters.
|
||||
|
||||
Reference in New Issue
Block a user