diff --git a/app/api/runs/[id]/stop/route.ts b/app/api/runs/[id]/stop/route.ts
new file mode 100644
index 0000000..add9b29
--- /dev/null
+++ b/app/api/runs/[id]/stop/route.ts
@@ -0,0 +1,34 @@
+import { getRun } from "@/lib/db";
+import { jobManager } from "@/lib/jobs";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function POST(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params;
+ const run = getRun(Number(id));
+ if (!run) {
+ return Response.json({ error: "run not found" }, { status: 404 });
+ }
+ if (run.status !== "running" && run.status !== "queued") {
+ return Response.json(
+ { error: `run ${run.id} 已结束(${run.status})` },
+ { status: 409 }
+ );
+ }
+ const result = jobManager.stopRun(run.id);
+ if (!result.ok) {
+ return Response.json(
+ { error: result.message ?? "停止失败" },
+ { status: 409 }
+ );
+ }
+ return Response.json(result.run ?? getRun(run.id));
+ } catch (err) {
+ return Response.json({ error: (err as Error).message }, { status: 500 });
+ }
+}
diff --git a/app/pipelines/[id]/page.tsx b/app/pipelines/[id]/page.tsx
index 2422ef0..77fd5e9 100644
--- a/app/pipelines/[id]/page.tsx
+++ b/app/pipelines/[id]/page.tsx
@@ -15,6 +15,7 @@ import {
} from "@/components/api";
import { LogViewer } from "@/components/log-viewer";
import { StatusBadge } from "@/components/status-badge";
+import { StopRunButton } from "@/components/stop-run-button";
import { fmtTime, fmtDuration } from "@/components/format";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -282,6 +283,10 @@ export default function PipelineDetailPage() {
#{latestRun.id}
+ {latestRun.status === "running" ||
+ latestRun.status === "queued" ? (
+
+ ) : null}
>
) : null}
diff --git a/app/runs/[id]/page.tsx b/app/runs/[id]/page.tsx
index 44de464..4209ae5 100644
--- a/app/runs/[id]/page.tsx
+++ b/app/runs/[id]/page.tsx
@@ -10,6 +10,7 @@ import {
} from "@/components/api";
import { LogViewer } from "@/components/log-viewer";
import { StatusBadge } from "@/components/status-badge";
+import { StopRunButton } from "@/components/stop-run-button";
import { fmtTime, fmtDuration } from "@/components/format";
import {
Card,
@@ -56,6 +57,9 @@ export default function RunDetailPage() {
运行 #{run.id}
+ {isActive ? (
+
+ ) : null}
diff --git a/components/status-badge.tsx b/components/status-badge.tsx
index adf4f10..7348546 100644
--- a/components/status-badge.tsx
+++ b/components/status-badge.tsx
@@ -15,6 +15,11 @@ const CONFIG: Record = {
label: "失败",
className: "border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400",
},
+ cancelled: {
+ label: "已取消",
+ className:
+ "border-yellow-500/40 bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
+ },
queued: {
label: "排队中",
className:
diff --git a/components/stop-run-button.tsx b/components/stop-run-button.tsx
new file mode 100644
index 0000000..8e31375
--- /dev/null
+++ b/components/stop-run-button.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+import { Square } from "lucide-react";
+import { api, ApiError } from "@/components/api";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { Button } from "@/components/ui/button";
+
+interface StopRunButtonProps {
+ runId: number;
+ onStopped?: () => void;
+}
+
+export function StopRunButton({ runId, onStopped }: StopRunButtonProps) {
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ async function stop() {
+ setLoading(true);
+ try {
+ await api(`/api/runs/${runId}/stop`, { method: "POST" });
+ toast.success("已发送停止请求");
+ setOpen(false);
+ onStopped?.();
+ } catch (e) {
+ if (e instanceof ApiError && e.status === 409) {
+ toast.info("运行已结束");
+ setOpen(false);
+ onStopped?.();
+ } else {
+ toast.error(e instanceof Error ? e.message : "停止失败");
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/instrumentation.ts b/instrumentation.ts
new file mode 100644
index 0000000..2061cf4
--- /dev/null
+++ b/instrumentation.ts
@@ -0,0 +1,7 @@
+export async function register() {
+ if (process.env.NEXT_RUNTIME === "nodejs") {
+ // Importing lib/jobs runs its module init: reconcile runs left in
+ // running/queued by a previous server process (orphan cleanup).
+ await import("./lib/jobs");
+ }
+}
diff --git a/lib/db.ts b/lib/db.ts
index e04995f..380b59d 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -64,7 +64,7 @@ export interface PipelineInput {
env?: string;
}
-export type RunStatus = "queued" | "running" | "success" | "failed";
+export type RunStatus = "queued" | "running" | "success" | "failed" | "cancelled";
// Normalize a JSON-object column value from an API body: accept either a
// plain object or a pre-serialized JSON string.
diff --git a/lib/jobs.ts b/lib/jobs.ts
index 6037adf..0160d60 100644
--- a/lib/jobs.ts
+++ b/lib/jobs.ts
@@ -1,4 +1,4 @@
-import { spawn } from "child_process";
+import { spawn, ChildProcess } from "child_process";
import fs from "fs";
import path from "path";
import {
@@ -6,6 +6,7 @@ import {
Pipeline,
Run,
createRun,
+ db,
getConnection,
getPipeline,
getRun,
@@ -43,10 +44,29 @@ interface ActiveRun {
tail: string[]; // last lines, used for the error summary
listeners: Set;
doneListeners: Set;
+ child: ChildProcess | null; // current subprocess (atlas or sling)
+ cancelled: boolean;
}
const TAIL_LIMIT = 20;
+// True if a process with this pid exists (EPERM means it exists but is owned
+// by another user).
+export function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (e) {
+ return (e as NodeJS.ErrnoException).code === "EPERM";
+ }
+}
+
+export interface StopResult {
+ ok: boolean;
+ message?: string;
+ run?: Run | null;
+}
+
class JobManager {
private active = new Map(); // runId -> state
private byPipeline = new Map(); // pipelineId -> runId
@@ -78,6 +98,8 @@ class JobManager {
tail: [],
listeners: new Set(),
doneListeners: new Set(),
+ child: null,
+ cancelled: false,
};
this.active.set(run.id, state);
this.byPipeline.set(pipelineId, run.id);
@@ -118,7 +140,69 @@ class JobManager {
for (const l of state.listeners) l(ev);
}
- private finish(state: ActiveRun, status: "success" | "failed", error: string | null) {
+ // Stop a run. If it is active in this process, flag it cancelled and
+ // SIGTERM the current subprocess (SIGKILL after 5s if it ignores SIGTERM);
+ // the execute loop observes `cancelled` and finishes as 'cancelled'.
+ // If the run is not active here but the DB still says running/queued, it
+ // belongs to a dead server process (HMR/restart): SIGTERM the recorded pid
+ // if still alive, otherwise just repair the stale DB row.
+ stopRun(runId: number): StopResult {
+ const state = this.active.get(runId);
+ if (state) {
+ state.cancelled = true;
+ const child = state.child;
+ if (child && child.pid && child.exitCode === null) {
+ this.emit(state, "[sling-ui] 收到停止请求,正在终止进程…");
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ // already exiting
+ }
+ setTimeout(() => {
+ if (state.child === child && child.exitCode === null) {
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ // already gone
+ }
+ }
+ }, 5000).unref();
+ }
+ return { ok: true, run: getRun(runId) };
+ }
+
+ const run = getRun(runId);
+ if (!run) return { ok: false, message: "run not found" };
+ if (run.status !== "running" && run.status !== "queued") {
+ return { ok: false, message: `run ${runId} already ${run.status}`, run };
+ }
+ const now = new Date().toISOString();
+ if (run.pid != null && isProcessAlive(run.pid)) {
+ try {
+ process.kill(run.pid, "SIGTERM");
+ } catch {
+ // died between the check and the kill
+ }
+ updateRunStatus(runId, {
+ status: "cancelled",
+ error: "用户手动停止",
+ finished_at: now,
+ });
+ } else {
+ updateRunStatus(runId, {
+ status: "failed",
+ error: "进程已退出(服务重启或异常)",
+ finished_at: now,
+ });
+ }
+ return { ok: true, run: getRun(runId) };
+ }
+
+ private finish(
+ state: ActiveRun,
+ status: "success" | "failed" | "cancelled",
+ error: string | null
+ ) {
updateRunStatus(state.runId, {
status,
error,
@@ -132,7 +216,12 @@ class JobManager {
}
private async execute(pipeline: Pipeline, state: ActiveRun) {
+ const finishCancelled = () => {
+ this.emit(state, "[sling-ui] 运行已被用户停止");
+ this.finish(state, "cancelled", "用户手动停止");
+ };
try {
+ if (state.cancelled) return finishCancelled();
if (pipeline.schema_sync) {
const src = getConnection(pipeline.source_conn_id);
const tgt = getConnection(pipeline.target_conn_id);
@@ -153,6 +242,7 @@ class JobManager {
buildApplyArgs(connectionUrl(src), connectionUrl(tgt), devUrl, excludes),
{}
);
+ if (state.cancelled) return finishCancelled();
if (code !== 0) {
throw new Error(
`atlas schema apply exited with code ${code}\n${state.tail.join("\n")}`
@@ -160,6 +250,7 @@ class JobManager {
}
}
+ if (state.cancelled) return finishCancelled();
const yamlPath = buildReplicationYaml(pipeline, state.runId);
const code = await this.runProcess(
state,
@@ -167,6 +258,7 @@ class JobManager {
["run", "-r", yamlPath, "--home-dir", SLING_HOME],
{ SLING_LOGGING: "JSON", SLING_DISABLE_TELEMETRY: "1" }
);
+ if (state.cancelled) return finishCancelled();
if (code === 0) {
this.finish(state, "success", null);
} else {
@@ -177,6 +269,7 @@ class JobManager {
);
}
} catch (err) {
+ if (state.cancelled) return finishCancelled();
this.emit(state, `[error] ${(err as Error).message}`);
this.finish(state, "failed", (err as Error).message);
}
@@ -193,6 +286,7 @@ class JobManager {
const child = spawn(bin, args, {
env: { ...process.env, ...extraEnv },
});
+ state.child = child;
if (child.pid) {
updateRunStatus(state.runId, { pid: child.pid });
}
@@ -216,7 +310,10 @@ class JobManager {
hookStream(child.stdout);
hookStream(child.stderr);
- child.on("close", (code) => resolve(code ?? 1));
+ child.on("close", (code) => {
+ if (state.child === child) state.child = null;
+ resolve(code ?? 1);
+ });
});
}
}
@@ -225,3 +322,56 @@ class JobManager {
const g = globalThis as unknown as { __jobManager?: JobManager };
export const jobManager: JobManager =
g.__jobManager ?? (g.__jobManager = new JobManager());
+
+// Reconcile runs left in running/queued by a previous server process.
+// A run whose recorded pid is dead is stale (the subprocess died with the
+// old server or never existed) -> failed. A run whose pid is still alive is
+// an orphaned sling/atlas subprocess that kept running after the old server
+// exited -> SIGTERM it and mark the run cancelled.
+function reconcileOrphanRuns() {
+ const stale = db
+ .prepare("SELECT * FROM runs WHERE status IN ('running', 'queued')")
+ .all() as Run[];
+ const now = new Date().toISOString();
+ for (const run of stale) {
+ const alive = run.pid != null && isProcessAlive(run.pid);
+ if (alive) {
+ try {
+ process.kill(run.pid!, "SIGTERM");
+ } catch {
+ // died between the check and the kill
+ }
+ updateRunStatus(run.id, {
+ status: "cancelled",
+ error: "服务重启,运行已终止",
+ finished_at: now,
+ });
+ } else {
+ updateRunStatus(run.id, {
+ status: "failed",
+ error: "服务重启或进程异常退出",
+ finished_at: now,
+ });
+ }
+ if (run.log_path && fs.existsSync(run.log_path)) {
+ try {
+ fs.appendFileSync(run.log_path, "[sling-ui] 服务重启,运行终止\n");
+ } catch {
+ // log append is best-effort
+ }
+ }
+ console.log(
+ `[sling-ui] reconciled orphan run #${run.id} -> ${alive ? "cancelled" : "failed"} (pid ${run.pid ?? "none"})`
+ );
+ }
+}
+
+const gReconcile = globalThis as unknown as { __runsReconciled?: boolean };
+if (!gReconcile.__runsReconciled) {
+ gReconcile.__runsReconciled = true;
+ try {
+ reconcileOrphanRuns();
+ } catch (e) {
+ console.error("[sling-ui] orphan run reconcile failed", e);
+ }
+}