Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f656baad1 | ||
|
|
3131d8ce5b | ||
|
|
d4e4c2c7d2 | ||
|
|
004586ee64 | ||
|
|
e6008a22cd |
@@ -1,20 +1,26 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import fs from "node:fs";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
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 { 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
const MAX_INITIAL_LOG_BYTES = 200_000;
|
|
||||||
|
|
||||||
interface RunDetailPageProps {
|
interface RunDetailPageProps {
|
||||||
params: Promise<{ runId: string }>;
|
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({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: RunDetailPageProps): Promise<Metadata> {
|
}: RunDetailPageProps): Promise<Metadata> {
|
||||||
@@ -27,34 +33,35 @@ export async function generateMetadata({
|
|||||||
return { title: run ? `${run.scriptName} run` : "Run not found" };
|
return { title: run ? `${run.scriptName} run` : "Run not found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTail(logFilePath: string): string {
|
|
||||||
if (!fs.existsSync(logFilePath)) return "";
|
|
||||||
const { size } = fs.statSync(logFilePath);
|
|
||||||
const start = Math.max(0, size - MAX_INITIAL_LOG_BYTES);
|
|
||||||
const fd = fs.openSync(logFilePath, "r");
|
|
||||||
try {
|
|
||||||
const buffer = Buffer.alloc(size - start);
|
|
||||||
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
||||||
return (start > 0 ? "... (truncated)\n" : "") + buffer.toString("utf-8");
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
||||||
const { runId } = await params;
|
const { runId } = await params;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
||||||
if (!run) notFound();
|
if (!run) notFound();
|
||||||
|
|
||||||
const initialLog = readTail(run.logFilePath);
|
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 (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{run.scriptName}</CardTitle>
|
<CardTitle>{run.scriptName}</CardTitle>
|
||||||
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
|
<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>
|
||||||
<dd>{run.triggeredBy}</dd>
|
<dd>{run.triggeredBy}</dd>
|
||||||
@@ -65,21 +72,45 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
|||||||
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
|
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt className="font-medium">Command</dt>
|
|
||||||
<dd className="truncate font-mono">{run.resolvedCommand}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium">Run ID</dt>
|
<dt className="font-medium">Run ID</dt>
|
||||||
<dd className="truncate font-mono">{run.id}</dd>
|
<dd className="truncate font-mono">{run.id}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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
|
<RunTerminal
|
||||||
runId={run.id}
|
runId={run.id}
|
||||||
initialStatus={run.status}
|
initialStatus={run.status}
|
||||||
initialLog={initialLog}
|
initialLog={initialLog}
|
||||||
|
initialLogBytes={initialLogBytes}
|
||||||
initialExitCode={run.exitCode}
|
initialExitCode={run.exitCode}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+188
-11
@@ -2,10 +2,14 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { desc } from "drizzle-orm";
|
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 { getDb } from "@/lib/db/client";
|
||||||
import { runs } from "@/lib/db/schema";
|
import { runs } from "@/lib/db/schema";
|
||||||
import { RunStatusBadge } from "@/components/runs/run-status-badge";
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -17,6 +21,28 @@ import {
|
|||||||
|
|
||||||
export const metadata: Metadata = { title: "Run History" };
|
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 {
|
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
|
||||||
if (!startedAt) return "-";
|
if (!startedAt) return "-";
|
||||||
const end = endedAt ?? new Date();
|
const end = endedAt ?? new Date();
|
||||||
@@ -29,30 +55,131 @@ function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
|
|||||||
return `${minutes}m ${seconds % 60}s`;
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RunsPage() {
|
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 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
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
.from(runs)
|
.from(runs)
|
||||||
.orderBy(desc(runs.createdAt))
|
.where(whereClause)
|
||||||
.limit(100)
|
.orderBy(...orderBy)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
.all();
|
.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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
|
||||||
|
<RunsToolbar scripts={scripts} />
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-12 text-center">No runs yet.</p>
|
<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">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Script</TableHead>
|
<TableHead>{sortHeader("script", "Script")}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>{sortHeader("status", "Status")}</TableHead>
|
||||||
<TableHead>Triggered by</TableHead>
|
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
|
||||||
<TableHead>Started</TableHead>
|
<TableHead>{sortHeader("started", "Started")}</TableHead>
|
||||||
<TableHead>Duration</TableHead>
|
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -85,6 +212,56 @@ export default function RunsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const styles: Record<RunStatus, string> = {
|
|||||||
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
const labels: Record<RunStatus, string> = {
|
export const runStatusLabels: Record<RunStatus, string> = {
|
||||||
queued: "Queued",
|
queued: "Queued",
|
||||||
running: "Running",
|
running: "Running",
|
||||||
succeeded: "Succeeded",
|
succeeded: "Succeeded",
|
||||||
@@ -31,7 +31,7 @@ export function RunStatusBadge({ status }: { status: RunStatus }) {
|
|||||||
{status === "running" && (
|
{status === "running" && (
|
||||||
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
|
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
|
||||||
)}
|
)}
|
||||||
{labels[status]}
|
{runStatusLabels[status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface RunTerminalProps {
|
|||||||
runId: string;
|
runId: string;
|
||||||
initialStatus: RunStatus;
|
initialStatus: RunStatus;
|
||||||
initialLog: string;
|
initialLog: string;
|
||||||
|
initialLogBytes: number;
|
||||||
initialExitCode: number | null;
|
initialExitCode: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +25,16 @@ export function RunTerminal({
|
|||||||
runId,
|
runId,
|
||||||
initialStatus,
|
initialStatus,
|
||||||
initialLog,
|
initialLog,
|
||||||
|
initialLogBytes,
|
||||||
initialExitCode,
|
initialExitCode,
|
||||||
}: RunTerminalProps) {
|
}: RunTerminalProps) {
|
||||||
const [status, setStatus] = useState<RunStatus>(initialStatus);
|
const [status, setStatus] = useState<RunStatus>(initialStatus);
|
||||||
const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
|
const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
|
||||||
const termRef = useRef<XtermViewHandle>(null);
|
const termRef = useRef<XtermViewHandle>(null);
|
||||||
|
|
||||||
const { cancel } = useRunSocket(runId, (message: ServerMessage) => {
|
const { cancel } = useRunSocket(
|
||||||
|
runId,
|
||||||
|
(message: ServerMessage) => {
|
||||||
if (message.type === "output") {
|
if (message.type === "output") {
|
||||||
// Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally
|
// Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally
|
||||||
// gets wrapped in red so failures stand out even from tools that don't colorize themselves.
|
// gets wrapped in red so failures stand out even from tools that don't colorize themselves.
|
||||||
@@ -40,15 +44,25 @@ export function RunTerminal({
|
|||||||
: message.chunk;
|
: message.chunk;
|
||||||
termRef.current?.write(chunk);
|
termRef.current?.write(chunk);
|
||||||
} else if (message.type === "status") {
|
} else if (message.type === "status") {
|
||||||
|
// The server also resends the run's current status right after subscribing, so a client
|
||||||
|
// that connects after a fast run has already finished still gets the real status instead
|
||||||
|
// of being stuck on whatever was known at page render. Only toast when it's new info.
|
||||||
|
const changed = message.status !== status;
|
||||||
setStatus(message.status);
|
setStatus(message.status);
|
||||||
setExitCode(message.exitCode ?? null);
|
setExitCode(message.exitCode ?? null);
|
||||||
if (message.status !== "queued" && message.status !== "running") {
|
if (
|
||||||
|
changed &&
|
||||||
|
message.status !== "queued" &&
|
||||||
|
message.status !== "running"
|
||||||
|
) {
|
||||||
toast.info(`Run ${message.status.replace("_", " ")}`);
|
toast.info(`Run ${message.status.replace("_", " ")}`);
|
||||||
}
|
}
|
||||||
} else if (message.type === "error") {
|
} else if (message.type === "error") {
|
||||||
toast.error(message.message);
|
toast.error(message.message);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
initialLogBytes,
|
||||||
|
);
|
||||||
|
|
||||||
const isActive = status === "queued" || status === "running";
|
const isActive = status === "queued" || status === "running";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { runStatusValues } from "@/lib/db/schema";
|
||||||
|
import { runStatusLabels } from "./run-status-badge";
|
||||||
|
|
||||||
|
interface RunsToolbarProps {
|
||||||
|
scripts: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunsToolbar({ scripts }: RunsToolbarProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const urlQ = searchParams.get("q") ?? "";
|
||||||
|
const [search, setSearch] = useState(urlQ);
|
||||||
|
// Tracks the URL value `search` was last synced from, so a change to the URL from elsewhere
|
||||||
|
// (back/forward, clearing filters) can be reflected without stomping on in-progress typing.
|
||||||
|
const [syncedQ, setSyncedQ] = useState(urlQ);
|
||||||
|
if (urlQ !== syncedQ) {
|
||||||
|
setSyncedQ(urlQ);
|
||||||
|
setSearch(urlQ);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParams(updates: Record<string, string | null>) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value === null) params.delete(key);
|
||||||
|
else params.set(key, value);
|
||||||
|
}
|
||||||
|
// Any filter/search change makes the current page number meaningless.
|
||||||
|
params.delete("page");
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (search === urlQ) return;
|
||||||
|
updateParams({ q: search || null });
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-debounce when the typed value changes
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="Search script, command, or triggered by..."
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={searchParams.get("status") ?? "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateParams({ status: value === "all" ? null : value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="sm:w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
{runStatusValues.map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{runStatusLabels[status]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={searchParams.get("scriptId") ?? "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateParams({ scriptId: value === "all" ? null : value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="sm:w-48">
|
||||||
|
<SelectValue placeholder="Script" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All scripts</SelectItem>
|
||||||
|
{scripts.map((script) => (
|
||||||
|
<SelectItem key={script.id} value={script.id}>
|
||||||
|
{script.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,15 @@ import type { ServerMessage } from "@/lib/ws/protocol";
|
|||||||
export function useRunSocket(
|
export function useRunSocket(
|
||||||
runId: string,
|
runId: string,
|
||||||
onMessage: (message: ServerMessage) => void,
|
onMessage: (message: ServerMessage) => void,
|
||||||
|
/** Byte offset of the log content the caller already has (e.g. from server-rendered initial
|
||||||
|
* log) - lets the server catch the socket up on anything written/broadcast before it
|
||||||
|
* subscribed, instead of resending from the start. */
|
||||||
|
afterBytes = 0,
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
|
const afterBytesRef = useRef(afterBytes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onMessageRef.current = onMessage;
|
onMessageRef.current = onMessage;
|
||||||
@@ -22,7 +27,13 @@ export function useRunSocket(
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
ws.send(JSON.stringify({ type: "subscribe", runId }));
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "subscribe",
|
||||||
|
runId,
|
||||||
|
afterBytes: afterBytesRef.current,
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const MAX_TAIL_BYTES = 200_000;
|
||||||
|
|
||||||
|
export interface LogTail {
|
||||||
|
text: string;
|
||||||
|
/** Total file size at read time - callers use this as an offset to later fetch only what's
|
||||||
|
* been appended since (see `readLogSince`). */
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads up to the last `maxBytes` of a log file. */
|
||||||
|
export function readLogTail(
|
||||||
|
logFilePath: string,
|
||||||
|
maxBytes = MAX_TAIL_BYTES,
|
||||||
|
): LogTail {
|
||||||
|
if (!fs.existsSync(logFilePath)) return { text: "", size: 0 };
|
||||||
|
const { size } = fs.statSync(logFilePath);
|
||||||
|
const start = Math.max(0, size - maxBytes);
|
||||||
|
const fd = fs.openSync(logFilePath, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(size - start);
|
||||||
|
fs.readSync(fd, buffer, 0, buffer.length, start);
|
||||||
|
return {
|
||||||
|
text: (start > 0 ? "... (truncated)\n" : "") + buffer.toString("utf-8"),
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads whatever has been appended to the log file after byte offset `fromByte`. Used to catch
|
||||||
|
* up a WS subscriber that connects after some output has already been written and broadcast -
|
||||||
|
* fast scripts can finish (and emit their whole output) before a client's WS subscribe message
|
||||||
|
* reaches the server, so a plain live-broadcast-only subscription would leave it stuck blank. */
|
||||||
|
export function readLogSince(logFilePath: string, fromByte: number): string {
|
||||||
|
if (!fs.existsSync(logFilePath)) return "";
|
||||||
|
const { size } = fs.statSync(logFilePath);
|
||||||
|
if (size <= fromByte) return "";
|
||||||
|
const fd = fs.openSync(logFilePath, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(size - fromByte);
|
||||||
|
fs.readSync(fd, buffer, 0, buffer.length, fromByte);
|
||||||
|
return buffer.toString("utf-8");
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,12 @@ function fieldSchema(variable: VariableConfig): z.ZodTypeAny {
|
|||||||
switch (variable.type) {
|
switch (variable.type) {
|
||||||
case "string": {
|
case "string": {
|
||||||
let s = z.string();
|
let s = z.string();
|
||||||
if (variable.minLength !== undefined) s = s.min(variable.minLength);
|
// `required` alone has to reject "" even when the author didn't set an explicit
|
||||||
|
// minLength - otherwise an empty string satisfies a bare z.string() and the run starts
|
||||||
|
// with a "required" field effectively unset.
|
||||||
|
const minLength =
|
||||||
|
variable.minLength ?? (variable.required ? 1 : undefined);
|
||||||
|
if (minLength !== undefined) s = s.min(minLength);
|
||||||
if (variable.maxLength !== undefined) s = s.max(variable.maxLength);
|
if (variable.maxLength !== undefined) s = s.max(variable.maxLength);
|
||||||
if (variable.pattern) s = s.regex(new RegExp(variable.pattern));
|
if (variable.pattern) s = s.regex(new RegExp(variable.pattern));
|
||||||
field = s;
|
field = s;
|
||||||
@@ -26,10 +31,13 @@ function fieldSchema(variable: VariableConfig): z.ZodTypeAny {
|
|||||||
case "enum":
|
case "enum":
|
||||||
field = z.enum(variable.choices as [string, ...string[]]);
|
field = z.enum(variable.choices as [string, ...string[]]);
|
||||||
break;
|
break;
|
||||||
case "multiselect":
|
case "multiselect": {
|
||||||
field = z.array(z.enum(variable.choices as [string, ...string[]]));
|
let arr = z.array(z.enum(variable.choices as [string, ...string[]]));
|
||||||
|
if (variable.required) arr = arr.min(1);
|
||||||
|
field = arr;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!variable.required) {
|
if (!variable.required) {
|
||||||
field = field.optional().or(z.literal(""));
|
field = field.optional().or(z.literal(""));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RunStatus } from "../db/schema";
|
import type { RunStatus } from "../db/schema";
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: "subscribe"; runId: string }
|
| { type: "subscribe"; runId: string; afterBytes?: number }
|
||||||
| { type: "unsubscribe"; runId: string }
|
| { type: "unsubscribe"; runId: string }
|
||||||
| { type: "cancel"; runId: string };
|
| { type: "cancel"; runId: string };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { getConfig } from "../config/load";
|
import { getConfig } from "../config/load";
|
||||||
import {
|
import {
|
||||||
extractCookieValue,
|
extractCookieValue,
|
||||||
@@ -8,14 +9,48 @@ import {
|
|||||||
} from "../auth/session";
|
} from "../auth/session";
|
||||||
import { hashToken, verifyTokenHash } from "../auth/tokens";
|
import { hashToken, verifyTokenHash } from "../auth/tokens";
|
||||||
import { getDb } from "../db/client";
|
import { getDb } from "../db/client";
|
||||||
import { apiTokens } from "../db/schema";
|
import { apiTokens, runs } from "../db/schema";
|
||||||
import { runEvents } from "../runner/events";
|
import { runEvents } from "../runner/events";
|
||||||
import { cancelRun } from "../runner/registry";
|
import { cancelRun } from "../runner/registry";
|
||||||
|
import { readLogSince } from "../runner/log-file";
|
||||||
import { isClientMessage, type ServerMessage } from "./protocol";
|
import { isClientMessage, type ServerMessage } from "./protocol";
|
||||||
|
|
||||||
const subscriptions = new Map<string, Set<WebSocket>>();
|
const subscriptions = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
function subscribe(runId: string, ws: WebSocket) {
|
/** A run can start and finish (emitting all its output+status over `runEvents`) before a
|
||||||
|
* client's `subscribe` message even reaches the server - fast scripts routinely beat the
|
||||||
|
* WS handshake + subscribe round trip. So every subscribe is answered with a catch-up: whatever
|
||||||
|
* log bytes exist past what the client already has (`afterBytes`, from its server-rendered
|
||||||
|
* initial log), plus the run's current status - before the socket starts receiving live
|
||||||
|
* broadcasts. Read-then-register (rather than register-then-read) trades a theoretical
|
||||||
|
* microsecond gap for guaranteeing no duplicated output. */
|
||||||
|
function subscribe(runId: string, ws: WebSocket, afterBytes: number) {
|
||||||
|
const run = getDb().select().from(runs).where(eq(runs.id, runId)).get();
|
||||||
|
if (run) {
|
||||||
|
const catchUp = readLogSince(run.logFilePath, afterBytes);
|
||||||
|
if (catchUp) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "output",
|
||||||
|
runId,
|
||||||
|
stream: "stdout",
|
||||||
|
chunk: catchUp,
|
||||||
|
seq: -1,
|
||||||
|
ts: Date.now(),
|
||||||
|
} satisfies ServerMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "status",
|
||||||
|
runId,
|
||||||
|
status: run.status,
|
||||||
|
exitCode: run.exitCode,
|
||||||
|
ts: Date.now(),
|
||||||
|
} satisfies ServerMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let set = subscriptions.get(runId);
|
let set = subscriptions.get(runId);
|
||||||
if (!set) {
|
if (!set) {
|
||||||
set = new Set();
|
set = new Set();
|
||||||
@@ -80,7 +115,7 @@ export function attachWsServer(wss: WebSocketServer) {
|
|||||||
|
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "subscribe":
|
case "subscribe":
|
||||||
subscribe(parsed.runId, ws);
|
subscribe(parsed.runId, ws, parsed.afterBytes ?? 0);
|
||||||
break;
|
break;
|
||||||
case "unsubscribe":
|
case "unsubscribe":
|
||||||
unsubscribe(parsed.runId, ws);
|
unsubscribe(parsed.runId, ws);
|
||||||
|
|||||||
Reference in New Issue
Block a user