Flatten the repo: move everything out of app/ to the root
Now that the CLI and the Next.js app are one package, nesting it inside app/ served no purpose - the repo root itself becomes the published npm package. Merges app/.gitignore and app/README.md into the root versions, drops the now-duplicate app/LICENSE, and updates path references (README, docs/ARCHITECTURE.md, docs/CONFIG_REFERENCE.md, package.json's repository.directory) that assumed the app/ nesting. Also fixes a real bug this surfaced: the in-app docs viewer resolved docs/ relative to process.cwd(), which only worked by accident when the CLI happened to be invoked from app/'s parent directory. A first attempt at fixing it with import.meta.dirname broke instead, for the same cross-module-graph reason config-path resolution already documented - Next compiles Route Handlers through a separate module graph that doesn't preserve source-relative import.meta paths. Fixed by exposing the app root via TRIGGERSHELL_APP_ROOT (set once in server.ts, where import.meta *does* resolve correctly), the same pattern already used for TRIGGERSHELL_CONFIG_PATH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { getDocMeta, readDocContent } from "@/lib/docs";
|
||||
import { MarkdownViewer } from "@/components/docs/markdown-viewer";
|
||||
|
||||
interface DocPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: DocPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return { title: getDocMeta(slug)?.title ?? "Doc not found" };
|
||||
}
|
||||
|
||||
export default async function DocPage({ params }: DocPageProps) {
|
||||
const { slug } = await params;
|
||||
const doc = getDocMeta(slug);
|
||||
if (!doc) notFound();
|
||||
|
||||
const content = readDocContent(doc);
|
||||
if (content === null) notFound();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
All docs
|
||||
</Link>
|
||||
<MarkdownViewer content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ChevronRight } from "lucide-react";
|
||||
import { DOCS } from "@/lib/docs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export const metadata: Metadata = { title: "Docs" };
|
||||
|
||||
export default function DocsIndexPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Docs</h1>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{DOCS.map((doc) => (
|
||||
<Link key={doc.slug} href={`/docs/${doc.slug}`}>
|
||||
<Card className="hover:border-foreground/30 h-full transition-colors">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="text-muted-foreground size-4.5 shrink-0" />
|
||||
<CardTitle className="text-base">{doc.title}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
{doc.description}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { getConfig } from "@/lib/config/load";
|
||||
import { requireAuth } from "@/lib/auth/guard";
|
||||
import { Nav } from "@/components/layout/nav";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { config } = getConfig();
|
||||
const auth = await requireAuth();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col">
|
||||
<Nav authEnabled={config.auth.enabled} username={auth.identity} />
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, PlayCircle } from "lucide-react";
|
||||
import { getConfig } from "@/lib/config/load";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export const metadata: Metadata = { title: "Scripts" };
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { config } = getConfig();
|
||||
|
||||
if (config.scripts.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground py-24 text-center">
|
||||
No scripts configured yet. Add entries under{" "}
|
||||
<code className="bg-muted rounded px-1.5 py-0.5">scripts:</code> in your
|
||||
config file.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Scripts</h1>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{config.scripts.map((script) => (
|
||||
<Link key={script.id} href={`/scripts/${script.id}`}>
|
||||
<Card className="hover:border-foreground/30 h-full transition-colors">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
|
||||
<CardTitle className="text-base">{script.name}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
|
||||
</CardHeader>
|
||||
{script.description && (
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
{script.description}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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 { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface RunDetailPageProps {
|
||||
params: Promise<{ runId: string }>;
|
||||
}
|
||||
|
||||
function formatVariableValue(value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") return "—";
|
||||
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: RunDetailPageProps): Promise<Metadata> {
|
||||
const { runId } = await params;
|
||||
const run = getDb()
|
||||
.select({ scriptName: runs.scriptName })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId))
|
||||
.get();
|
||||
return { title: run ? `${run.scriptName} run` : "Run not found" };
|
||||
}
|
||||
|
||||
export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
||||
const { runId } = await params;
|
||||
const db = getDb();
|
||||
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
||||
if (!run) notFound();
|
||||
|
||||
const { text: initialLog, size: initialLogBytes } = readLogTail(
|
||||
run.logFilePath,
|
||||
);
|
||||
|
||||
const script = getScript(run.scriptId);
|
||||
const variableEntries = script
|
||||
? script.variables.map((variable) => ({
|
||||
key: variable.name,
|
||||
label: variable.label ?? variable.name,
|
||||
value: run.variables[variable.name],
|
||||
}))
|
||||
: Object.entries(run.variables).map(([key, value]) => ({
|
||||
key,
|
||||
label: key,
|
||||
value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||
<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>
|
||||
<dd>{run.triggeredBy}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium">Started</dt>
|
||||
<dd>
|
||||
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium">Run ID</dt>
|
||||
<dd className="truncate font-mono">{run.id}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Command
|
||||
</span>
|
||||
<pre className="bg-muted overflow-x-auto rounded-md p-3 font-mono text-xs whitespace-pre">
|
||||
{run.resolvedCommand}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{variableEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Variables
|
||||
</span>
|
||||
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2">
|
||||
{variableEntries.map(({ key, label, value }) => (
|
||||
<div key={key} className="flex flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="font-mono break-all">
|
||||
{formatVariableValue(value)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RunTerminal
|
||||
runId={run.id}
|
||||
initialStatus={run.status}
|
||||
initialLog={initialLog}
|
||||
initialLogBytes={initialLogBytes}
|
||||
initialExitCode={run.exitCode}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { runs } from "@/lib/db/schema";
|
||||
import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge";
|
||||
import { RunsToolbar } from "@/components/runs/runs-toolbar";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const metadata: Metadata = { title: "Run History" };
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
// Duration isn't a stored column - it's derived from started_at/ended_at (both unix seconds), so
|
||||
// it needs a SQL expression rather than a plain column reference to be sortable. Still-running
|
||||
// rows (ended_at IS NULL) count as elapsed-so-far; never-started (queued) rows sort as NULL,
|
||||
// which SQLite puts first in ASC / last in DESC - keeping them out of the way either direction.
|
||||
const durationExpr = sql`case when ${runs.startedAt} is null then null else coalesce(${runs.endedAt}, unixepoch()) - ${runs.startedAt} end`;
|
||||
|
||||
const SORT_COLUMNS = {
|
||||
script: runs.scriptName,
|
||||
status: runs.status,
|
||||
triggeredBy: runs.triggeredBy,
|
||||
started: runs.startedAt,
|
||||
duration: durationExpr,
|
||||
} as const;
|
||||
type SortKey = keyof typeof SORT_COLUMNS;
|
||||
const DEFAULT_SORT: SortKey = "started";
|
||||
|
||||
function isSortKey(value: string | undefined): value is SortKey {
|
||||
return !!value && value in SORT_COLUMNS;
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
|
||||
if (!startedAt) return "-";
|
||||
const end = endedAt ?? new Date();
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.round((end.getTime() - startedAt.getTime()) / 1000),
|
||||
);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
|
||||
interface RunsPageProps {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
status?: string;
|
||||
scriptId?: string;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function RunsPage({ searchParams }: RunsPageProps) {
|
||||
const params = await searchParams;
|
||||
const search = params.q?.trim() ?? "";
|
||||
const statusFilter = params.status ?? "all";
|
||||
const scriptFilter = params.scriptId ?? "all";
|
||||
const sort = isSortKey(params.sort) ? params.sort : DEFAULT_SORT;
|
||||
const dir = params.dir === "asc" ? "asc" : "desc";
|
||||
const requestedPage = Math.max(1, Math.floor(Number(params.page)) || 1);
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
if (search) {
|
||||
const term = `%${search}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
like(runs.scriptName, term),
|
||||
like(runs.triggeredBy, term),
|
||||
like(runs.resolvedCommand, term),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
|
||||
conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels));
|
||||
}
|
||||
if (scriptFilter !== "all") {
|
||||
conditions.push(eq(runs.scriptId, scriptFilter));
|
||||
}
|
||||
const whereClause = conditions.length ? and(...conditions) : undefined;
|
||||
|
||||
const orderColumn = SORT_COLUMNS[sort];
|
||||
const orderBy = [
|
||||
dir === "asc" ? asc(orderColumn) : desc(orderColumn),
|
||||
desc(runs.createdAt),
|
||||
];
|
||||
|
||||
const [{ total }] = db
|
||||
.select({ total: sql<number>`count(*)` })
|
||||
.from(runs)
|
||||
.where(whereClause)
|
||||
.all();
|
||||
const scripts = db
|
||||
.selectDistinct({ id: runs.scriptId, name: runs.scriptName })
|
||||
.from(runs)
|
||||
.orderBy(asc(runs.scriptName))
|
||||
.all();
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const page = Math.min(requestedPage, totalPages);
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(runs)
|
||||
.where(whereClause)
|
||||
.orderBy(...orderBy)
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
.all();
|
||||
|
||||
function hrefFor(overrides: Record<string, string | null>): string {
|
||||
const query = new URLSearchParams();
|
||||
if (search) query.set("q", search);
|
||||
if (statusFilter !== "all") query.set("status", statusFilter);
|
||||
if (scriptFilter !== "all") query.set("scriptId", scriptFilter);
|
||||
if (sort !== DEFAULT_SORT) query.set("sort", sort);
|
||||
if (dir !== "desc") query.set("dir", dir);
|
||||
if (page !== 1) query.set("page", String(page));
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value === null) query.delete(key);
|
||||
else query.set(key, value);
|
||||
}
|
||||
const qs = query.toString();
|
||||
return qs ? `/runs?${qs}` : "/runs";
|
||||
}
|
||||
|
||||
function sortHeader(key: SortKey, label: string) {
|
||||
const active = sort === key;
|
||||
const nextDir = active && dir === "asc" ? "desc" : "asc";
|
||||
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
|
||||
return (
|
||||
<Link
|
||||
href={hrefFor({ sort: key, dir: nextDir, page: null })}
|
||||
className={cn(
|
||||
"flex items-center gap-1 hover:text-foreground",
|
||||
active && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<Icon className="size-3.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
|
||||
<RunsToolbar scripts={scripts} />
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-muted-foreground py-12 text-center">
|
||||
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all"
|
||||
? "No runs yet."
|
||||
: "No runs match these filters."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{sortHeader("script", "Script")}</TableHead>
|
||||
<TableHead>{sortHeader("status", "Status")}</TableHead>
|
||||
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
|
||||
<TableHead>{sortHeader("started", "Started")}</TableHead>
|
||||
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((run) => (
|
||||
<TableRow key={run.id} className="cursor-pointer">
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/runs/${run.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{run.scriptName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{run.triggeredBy}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{run.startedAt
|
||||
? new Date(run.startedAt).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDuration(run.startedAt, run.endedAt)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center justify-between text-sm">
|
||||
<span>
|
||||
Page {page} of {totalPages} · {total} run{total === 1 ? "" : "s"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={hrefFor({ page: String(page - 1) })}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
aria-disabled
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
className: "pointer-events-none opacity-50",
|
||||
})}
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Previous
|
||||
</span>
|
||||
)}
|
||||
{page < totalPages ? (
|
||||
<Link
|
||||
href={hrefFor({ page: String(page + 1) })}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-3.5" />
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
aria-disabled
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
className: "pointer-events-none opacity-50",
|
||||
})}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface ScriptPageProps {
|
||||
params: Promise<{ scriptId: string }>;
|
||||
searchParams: Promise<{ fromRun?: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ScriptPageProps): Promise<Metadata> {
|
||||
const { scriptId } = await params;
|
||||
const script = getScript(scriptId);
|
||||
return { title: script?.name ?? "Script not found" };
|
||||
}
|
||||
|
||||
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>
|
||||
<CardHeader>
|
||||
<CardTitle>{script.name}</CardTitle>
|
||||
{script.description && (
|
||||
<CardDescription>{script.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicForm script={clientScript} initialValues={initialValues} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user