feat: local web UI for DB migration via Atlas (schema) + sling (data)

- Connections CRUD with test (sling conns test) and table discovery (sling conns discover)
- Pipelines: source/target, mode, schema_sync toggle, per-table PK/UK for incremental
- Run engine: atlas schema apply then sling run -r, JSON log parsing, SSE live logs
- Runs history with log replay; dashboard with binary health check
- Atlas dev schema on target server (sling_ui_atlas_dev) instead of docker dev-url
- full-refresh translated to truncate when schema_sync is on to preserve Atlas-built DDL
This commit is contained in:
tigerenwork 2026-08-09 17:10:17 +08:00
parent efc1e413b0
commit fcb0f2e299
56 changed files with 6361 additions and 107 deletions

3
.gitignore vendored
View File

@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# local runtime data
/data/

View File

@ -1,36 +1,56 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # sling-ui
## Getting Started 一个本地运行的 Web UI把「测试环境 → 本地 dev 库」的数据同步流程自动化:
First, run the development server: - **Schema 迁移**[Atlas](https://atlasgo.io)(表 / 索引 / 主外键约束)
- **数据迁移**[sling](https://slingdata.io)full-refresh / truncate / incremental / snapshot
第一阶段支持 MySQL / MariaDB / TiDB。
## 前置依赖
- Node.js 20+ 与 pnpm
- `sling` CLI`curl -LO 'https://github.com/slingdata-io/sling-cli/releases/latest/download/sling_darwin_arm64.tar.gz' ...`,见官方文档;需要官方构建,`conns discover` 在 OSS 自编译版不可用)
- `atlas` CLI`brew install ariga/tap/atlas`
启动后应用会在顶部 banner 检测这两个二进制是否可用。
## 启动
```bash ```bash
npm run dev pnpm install
# or pnpm dev # 或 pnpm build && pnpm start
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 打开 http://localhost:3000 。
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ## 使用流程
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 1. **Connections**:新建源(测试环境)和目标(本地 dev连接点「测试连接」确认可用。
2. **Pipelines → 新建流水线**:选源/目标连接、mode、是否开启 schema sync「加载表」勾选要迁移的表。
3. **流水线详情**
- 「Schema Diff 预览」查看 Atlas dry-run 生成的 DDL
- 「运行」执行迁移:先 `atlas schema apply`(同步表结构,含索引/约束),再 `sling run`(灌数据),日志实时流式展示。
4. **Runs**:全部运行历史与日志回放。
## Learn More ### mode 说明
To learn more about Next.js, take a look at the following resources: - `schema_sync` 开启时Atlas 负责目标端表结构;此时即使选择 `full-refresh`,实际执行会翻译为 `truncate`(保留 Atlas 建的表结构重灌全量数据,避免 sling drop+重建丢掉索引/约束)。
- `incremental` 需要为每张表配置 `primary_key``update_key`
- sling 连接 URL 自动带 `foreign_key_checks=0`(会话级),避免外键顺序问题;约束定义本身保留。
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## 数据与安全说明
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - 所有应用数据在项目根 `data/` 目录(已 gitignoreSQLite`sling-ui.db`)、生成的 sling `env.yaml`、replication yaml、运行日志。
- **连接密码以明文存储在本地 SQLite**;运行日志中的命令行已做密码脱敏,但 sling/atlas 自身的输出可能仍含连接信息。本工具定位为本地单人使用,请勿暴露到网络。
- Atlas 需要一个 dev database 做 schema 规范化:实现为在**目标服务器**上创建专用 scratch schema `sling_ui_atlas_dev`(自动创建,保持干净),不依赖 docker。
- sling 使用应用自管的 home 目录(`data/sling-home`),不会读写你的 `~/.sling`
## Deploy on Vercel ## 技术栈
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Next.js 16App Router+ TypeScript + Tailwind 4 + shadcn/ui + better-sqlite3通过 shell 调用 sling / atlas 二进制,`SLING_LOGGING=JSON` 解析结构化日志SSE 实时推送。
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## 路线图
- PostgreSQL 支持(连接类型 + pg_dump/Atlas 均已抽象)
- 流水线编辑 UI、定时调度、运行并发队列

View File

@ -0,0 +1,34 @@
import { deleteConnection, updateConnection } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Ctx) {
try {
const { id } = await params;
const body = await request.json();
const conn = updateConnection(Number(id), body);
if (!conn) {
return Response.json({ error: "connection not found" }, { status: 404 });
}
return Response.json(conn);
} catch (err) {
const message = (err as Error).message;
const status = message.includes("UNIQUE") ? 409 : 500;
return Response.json({ error: message }, { status });
}
}
export async function DELETE(_request: Request, { params }: Ctx) {
try {
const { id } = await params;
if (!deleteConnection(Number(id))) {
return Response.json({ error: "connection not found" }, { status: 404 });
}
return Response.json({ ok: true });
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,16 @@
import { discoverStreams } from "@/lib/sling";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
return Response.json(discoverStreams(Number(id)));
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,17 @@
import { testConnection } from "@/lib/sling";
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 result = testConnection(Number(id));
return Response.json(result);
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,38 @@
import { createConnection, listConnections } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
return Response.json(listConnections());
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
if (!body.name || !body.type || !body.host) {
return Response.json(
{ error: "name, type and host are required" },
{ status: 400 }
);
}
const conn = createConnection({
name: String(body.name),
type: String(body.type),
host: String(body.host),
port: Number(body.port ?? 3306),
user: String(body.user ?? ""),
password: String(body.password ?? ""),
database: String(body.database ?? ""),
});
return Response.json(conn, { status: 201 });
} catch (err) {
const message = (err as Error).message;
const status = message.includes("UNIQUE") ? 409 : 500;
return Response.json({ error: message }, { status });
}
}

12
app/api/health/route.ts Normal file
View File

@ -0,0 +1,12 @@
import { checkBinaries } from "@/lib/binaries";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
return Response.json(checkBinaries());
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,25 @@
import { schemaDiff } from "@/lib/atlas";
import { getPipeline } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const pipeline = getPipeline(Number(id));
if (!pipeline) {
return Response.json({ error: "pipeline not found" }, { status: 404 });
}
const result = await schemaDiff(
pipeline.source_conn_id,
pipeline.target_conn_id
);
return Response.json(result);
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,63 @@
import { deletePipeline, getPipeline, updatePipeline } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
function normalizeStreams(raw: unknown): string {
if (typeof raw === "string") return raw;
return JSON.stringify(raw ?? []);
}
export async function GET(_request: Request, { params }: Ctx) {
try {
const { id } = await params;
const pipeline = getPipeline(Number(id));
if (!pipeline) {
return Response.json({ error: "pipeline not found" }, { status: 404 });
}
return Response.json(pipeline);
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}
export async function PUT(request: Request, { params }: Ctx) {
try {
const { id } = await params;
const body = await request.json();
const patch: Record<string, unknown> = {};
for (const key of [
"name",
"source_conn_id",
"target_conn_id",
"schema_sync",
"mode",
]) {
if (body[key] !== undefined) patch[key] = body[key];
}
if (body.streams !== undefined) {
patch.streams = normalizeStreams(body.streams);
}
const pipeline = updatePipeline(Number(id), patch);
if (!pipeline) {
return Response.json({ error: "pipeline not found" }, { status: 404 });
}
return Response.json(pipeline);
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}
export async function DELETE(_request: Request, { params }: Ctx) {
try {
const { id } = await params;
if (!deletePipeline(Number(id))) {
return Response.json({ error: "pipeline not found" }, { status: 404 });
}
return Response.json({ ok: true });
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,22 @@
import { AlreadyRunningError, 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 = jobManager.startRun(Number(id));
return Response.json(run, { status: 201 });
} catch (err) {
if (err instanceof AlreadyRunningError) {
return Response.json({ error: err.message }, { status: 409 });
}
const message = (err as Error).message;
const status = message.includes("not found") ? 404 : 500;
return Response.json({ error: message }, { status });
}
}

View File

@ -0,0 +1,40 @@
import { createPipeline, listPipelines } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function normalizeStreams(raw: unknown): string {
if (typeof raw === "string") return raw;
return JSON.stringify(raw ?? []);
}
export async function GET() {
try {
return Response.json(listPipelines());
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
if (!body.name || !body.source_conn_id || !body.target_conn_id) {
return Response.json(
{ error: "name, source_conn_id and target_conn_id are required" },
{ status: 400 }
);
}
const pipeline = createPipeline({
name: String(body.name),
source_conn_id: Number(body.source_conn_id),
target_conn_id: Number(body.target_conn_id),
schema_sync: body.schema_sync === undefined ? 1 : Number(body.schema_sync),
mode: body.mode ? String(body.mode) : "full-refresh",
streams: normalizeStreams(body.streams),
});
return Response.json(pipeline, { status: 201 });
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,93 @@
import fs from "fs";
import { getRun } from "@/lib/db";
import { jobManager } from "@/lib/jobs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const encoder = new TextEncoder();
function sseLine(line: string, ts: number): Uint8Array {
return encoder.encode(`data: ${JSON.stringify({ line, ts })}\n\n`);
}
function sseDone(status: string): Uint8Array {
return encoder.encode(`event: done\ndata: ${JSON.stringify({ status })}\n\n`);
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const runId = Number(id);
const run = getRun(runId);
if (!run) {
return Response.json({ error: "run not found" }, { status: 404 });
}
let cleanup: () => void = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let unsubscribe: (() => void) | null = null;
let closed = false;
const close = (status: string) => {
if (closed) return;
closed = true;
try {
controller.enqueue(sseDone(status));
controller.close();
} catch {
// client already gone
}
};
const replayFile = () => {
if (run.log_path && fs.existsSync(run.log_path)) {
const content = fs.readFileSync(run.log_path, "utf8");
for (const line of content.split("\n")) {
if (line) controller.enqueue(sseLine(line, 0));
}
}
close(run.status);
};
if (run.status === "running" || run.status === "queued") {
unsubscribe = jobManager.subscribe(
runId,
(ev) => {
if (!closed) controller.enqueue(sseLine(ev.line, ev.ts));
},
(status) => close(status)
);
if (!unsubscribe) {
// Not active in this process (e.g. after HMR): fall back to file.
replayFile();
return;
}
cleanup = () => {
closed = true;
unsubscribe?.();
};
} else {
replayFile();
}
},
cancel() {
cleanup();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@ -0,0 +1,25 @@
import fs from "fs";
import { getRun } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_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 });
}
let log = "";
if (run.log_path && fs.existsSync(run.log_path)) {
log = fs.readFileSync(run.log_path, "utf8");
}
return Response.json({ ...run, log });
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

12
app/api/runs/route.ts Normal file
View File

@ -0,0 +1,12 @@
import { listRuns } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
return Response.json(listRuns());
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}

205
app/connections/page.tsx Normal file
View File

@ -0,0 +1,205 @@
"use client";
import { Fragment, useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Plus, Trash2, Pencil, PlugZap } from "lucide-react";
import { api, type Connection } from "@/components/api";
import { fmtTime } from "@/components/format";
import { ConnectionDialog } from "@/components/connection-dialog";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface TestResult {
ok: boolean;
output: string;
}
export default function ConnectionsPage() {
const [list, setList] = useState<Connection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Connection | null>(null);
const [deleting, setDeleting] = useState<Connection | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [testingId, setTestingId] = useState<number | null>(null);
const [testResults, setTestResults] = useState<Record<number, TestResult>>({});
const [expandedId, setExpandedId] = useState<number | null>(null);
const load = useCallback(() => {
api<Connection[]>("/api/connections")
.then(setList)
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
async function testConn(c: Connection) {
setTestingId(c.id);
try {
const res = await api<TestResult>(`/api/connections/${c.id}/test`, {
method: "POST",
});
setTestResults((m) => ({ ...m, [c.id]: res }));
setExpandedId(c.id);
if (res.ok) toast.success(`连接「${c.name}」测试成功`);
else toast.error(`连接「${c.name}」测试失败`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "测试失败");
} finally {
setTestingId(null);
}
}
async function doDelete() {
if (!deleting) return;
setDeleteLoading(true);
try {
await api(`/api/connections/${deleting.id}`, { method: "DELETE" });
toast.success(`已删除连接「${deleting.name}`);
setDeleting(null);
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
} finally {
setDeleteLoading(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Connections</h1>
<Button
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus className="size-4" />
</Button>
</div>
{loading ? (
<div className="text-sm text-muted-foreground"></div>
) : error ? (
<div className="text-sm text-red-500">{error}</div>
) : list.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
MySQL / MariaDB / TiDB
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Host</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-56"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((c) => (
<Fragment key={c.id}>
<TableRow>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-mono text-xs">{c.type}</TableCell>
<TableCell className="font-mono text-xs">
{c.host}:{c.port}
</TableCell>
<TableCell className="font-mono text-xs">{c.database}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtTime(c.created_at)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={testingId === c.id}
onClick={() => testConn(c)}
>
<PlugZap className="size-3.5" />
{testingId === c.id ? "测试中…" : "测试"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(c);
setDialogOpen(true);
}}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleting(c)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
{expandedId === c.id && testResults[c.id] ? (
<TableRow>
<TableCell colSpan={6} className="bg-muted/40">
<pre
className={
"max-h-48 overflow-auto whitespace-pre-wrap break-all rounded p-2 font-mono text-xs " +
(testResults[c.id].ok
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400")
}
>
{testResults[c.id].output ||
(testResults[c.id].ok ? "连接成功" : "连接失败")}
</pre>
</TableCell>
</TableRow>
) : null}
</Fragment>
))}
</TableBody>
</Table>
</div>
)}
{dialogOpen ? (
<ConnectionDialog
key={editing?.id ?? "new"}
open={dialogOpen}
onOpenChange={setDialogOpen}
initial={editing}
onSaved={load}
/>
) : null}
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title={`删除连接「${deleting?.name ?? ""}」?`}
description="删除后引用该连接的流水线将无法运行。"
loading={deleteLoading}
onConfirm={doDelete}
/>
</div>
);
}

View File

@ -1,26 +1,130 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root { @custom-variant dark (&:is(.dark *));
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
} }
@media (prefers-color-scheme: dark) { :root {
:root { --background: oklch(1 0 0);
--background: #0a0a0a; --foreground: oklch(0.145 0 0);
--foreground: #ededed; --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
} }
} }
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -1,6 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import { AppSidebar } from "@/components/app-sidebar";
import { HealthBanner } from "@/components/health-banner";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -13,17 +16,26 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "sling-ui",
description: "Generated by create next app", description: "Atlas + Sling 数据同步控制台",
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html
lang="en" lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full">
<div className="flex min-h-screen">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<HealthBanner />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
<Toaster />
</body>
</html> </html>
); );
} }

View File

@ -1,69 +1,125 @@
import Image from "next/image"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
api,
type Connection,
type Pipeline,
type RunWithPipeline,
} from "@/components/api";
import { StatusBadge } from "@/components/status-badge";
import { fmtTime, fmtDuration } from "@/components/format";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
export default function DashboardPage() {
const [connections, setConnections] = useState<Connection[]>([]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [runs, setRuns] = useState<RunWithPipeline[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([
api<Connection[]>("/api/connections"),
api<Pipeline[]>("/api/pipelines"),
api<RunWithPipeline[]>("/api/runs"),
])
.then(([c, p, r]) => {
setConnections(c);
setPipelines(p);
setRuns(r);
})
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="text-sm text-muted-foreground"></div>;
if (error) return <div className="text-sm text-red-500">{error}</div>;
const recent = runs.slice(0, 5);
const stats = [
{ label: "连接数", value: connections.length, href: "/connections" },
{ label: "流水线数", value: pipelines.length, href: "/pipelines" },
{ label: "运行总数", value: runs.length, href: "/runs" },
];
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="flex flex-col gap-6">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <h1 className="text-xl font-semibold">Dashboard</h1>
<Image
className="dark:invert h-5 w-[100px]" <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
src="/next.svg" {stats.map((s) => (
alt="Next.js logo" <Card key={s.label}>
width={100} <CardHeader>
height={20} <CardTitle className="text-sm text-muted-foreground">
priority {s.label}
/> </CardTitle>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> </CardHeader>
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <CardContent>
To get started, edit the{" "} <Link href={s.href} className="text-3xl font-semibold hover:underline">
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]"> {s.value}
page.tsx </Link>
</code>{" "} </CardContent>
file. </Card>
</h1> ))}
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> </div>
Looking for a starting point or more instructions? Head over to{" "}
<a <Card>
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <CardHeader>
className="font-medium text-zinc-950 dark:text-zinc-50" <CardTitle></CardTitle>
> </CardHeader>
Templates <CardContent>
</a>{" "} {recent.length === 0 ? (
or the{" "} <div className="text-sm text-muted-foreground"></div>
<a ) : (
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <Table>
className="font-medium text-zinc-950 dark:text-zinc-50" <TableHeader>
> <TableRow>
Learning <TableHead className="w-16">ID</TableHead>
</a>{" "} <TableHead>线</TableHead>
center. <TableHead></TableHead>
</p> <TableHead></TableHead>
</div> <TableHead></TableHead>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> <TableHead className="w-20"></TableHead>
<a </TableRow>
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" </TableHeader>
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <TableBody>
target="_blank" {recent.map((r) => (
rel="noopener noreferrer" <TableRow key={r.id}>
> <TableCell className="font-mono">#{r.id}</TableCell>
<Image <TableCell>{r.pipeline_name ?? `(已删除 #${r.pipeline_id})`}</TableCell>
className="dark:invert h-[14px] w-4" <TableCell>
src="/vercel.svg" <StatusBadge status={r.status} />
alt="Vercel logomark" </TableCell>
width={16} <TableCell>{fmtTime(r.started_at)}</TableCell>
height={14} <TableCell>{fmtDuration(r.started_at, r.finished_at)}</TableCell>
/> <TableCell>
Deploy Now <Button variant="link" size="sm">
</a> <Link href={`/runs/${r.id}`}></Link>
<a </Button>
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]" </TableCell>
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" </TableRow>
target="_blank" ))}
rel="noopener noreferrer" </TableBody>
> </Table>
Documentation )}
</a> </CardContent>
</div> </Card>
</main>
</div> </div>
); );
} }

309
app/pipelines/[id]/page.tsx Normal file
View File

@ -0,0 +1,309 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import { Play, GitCompareArrows } from "lucide-react";
import {
api,
ApiError,
type Connection,
type Pipeline,
type RunWithPipeline,
type StreamConfig,
} from "@/components/api";
import { LogViewer } from "@/components/log-viewer";
import { StatusBadge } from "@/components/status-badge";
import { fmtTime, fmtDuration } from "@/components/format";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function PipelineDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const [pipeline, setPipeline] = useState<Pipeline | null>(null);
const [connMap, setConnMap] = useState<Record<number, string>>({});
const [runs, setRuns] = useState<RunWithPipeline[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [runLoading, setRunLoading] = useState(false);
const [diffLoading, setDiffLoading] = useState(false);
const [diffSql, setDiffSql] = useState<string | null>(null);
const loadRuns = useCallback(() => {
api<RunWithPipeline[]>("/api/runs")
.then((all) =>
setRuns(all.filter((r) => r.pipeline_id === id))
)
.catch(() => {});
}, [id]);
useEffect(() => {
Promise.all([
api<Pipeline>(`/api/pipelines/${id}`),
api<Connection[]>("/api/connections"),
])
.then(([p, conns]) => {
setPipeline(p);
setConnMap(Object.fromEntries(conns.map((c) => [c.id, c.name])));
})
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
loadRuns();
}, [id, loadRuns]);
// 有运行中的任务时轮询刷新历史列表
const hasActive = runs.some(
(r) => r.status === "running" || r.status === "queued"
);
useEffect(() => {
if (!hasActive) return;
const t = setInterval(loadRuns, 4000);
return () => clearInterval(t);
}, [hasActive, loadRuns]);
const streams = useMemo<StreamConfig[]>(() => {
if (!pipeline) return [];
try {
const v = JSON.parse(pipeline.streams);
return Array.isArray(v) ? v : [];
} catch {
return [];
}
}, [pipeline]);
const latestRun = runs[0] ?? null;
async function runNow() {
setRunLoading(true);
try {
await api(`/api/pipelines/${id}/run`, { method: "POST" });
toast.success("已开始运行");
loadRuns();
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
toast.info("该流水线正在运行中,请等待当前运行结束");
} else {
toast.error(e instanceof Error ? e.message : "启动失败");
}
} finally {
setRunLoading(false);
}
}
async function previewDiff() {
setDiffLoading(true);
setDiffSql(null);
try {
const res = await api<{ ok: boolean; sql: string }>(
`/api/pipelines/${id}/diff`
);
setDiffSql(res.sql || "-- 无差异schema 已一致");
if (!res.ok) toast.info("Atlas 返回了非零状态,请检查 SQL 内容");
} catch (e) {
toast.error(e instanceof Error ? e.message : "获取 diff 失败");
} finally {
setDiffLoading(false);
}
}
if (loading) return <div className="text-sm text-muted-foreground"></div>;
if (error || !pipeline)
return (
<div className="text-sm text-red-500">
{error ? `加载失败:${error}` : "流水线不存在"}
</div>
);
const connName = (cid: number) => connMap[cid] ?? `#${cid}`;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">{pipeline.name}</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={previewDiff} disabled={diffLoading}>
<GitCompareArrows className="size-4" />
{diffLoading ? "Diff 计算中…" : "Schema Diff 预览"}
</Button>
<Button onClick={runNow} disabled={runLoading}>
<Play className="size-4" />
{runLoading ? "启动中…" : "运行"}
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground"> </dt>
<dd className="font-medium">
{connName(pipeline.source_conn_id)} {" "}
{connName(pipeline.target_conn_id)}
</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-mono">{pipeline.mode}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{streams.length}</dd>
</div>
<div>
<dt className="text-muted-foreground">Schema </dt>
<dd>
{pipeline.schema_sync ? (
<Badge variant="outline" className="border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400">
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{fmtTime(pipeline.created_at)}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{fmtTime(pipeline.updated_at)}</dd>
</div>
</dl>
{streams.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1">
{streams.map((s) => (
<Badge key={s.name} variant="secondary" className="font-mono">
{s.name}
</Badge>
))}
</div>
) : null}
</CardContent>
</Card>
{diffLoading || diffSql !== null ? (
<Card>
<CardHeader>
<CardTitle>Schema DiffAtlas dry-run</CardTitle>
<CardDescription>
DDL
</CardDescription>
</CardHeader>
<CardContent>
{diffLoading ? (
<div className="text-sm text-muted-foreground">
diff
</div>
) : (
<Textarea
readOnly
value={diffSql ?? ""}
className="h-64 font-mono text-xs"
/>
)}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{latestRun ? (
<>
<span className="font-mono text-sm text-muted-foreground">
#{latestRun.id}
</span>
<StatusBadge status={latestRun.status} />
</>
) : null}
</CardTitle>
</CardHeader>
<CardContent>
{latestRun ? (
<LogViewer
key={latestRun.id}
runId={latestRun.id}
live
emptyText="暂无日志输出"
onDone={loadRuns}
/>
) : (
<div className="text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono">#{r.id}</TableCell>
<TableCell>
<StatusBadge status={r.status} />
</TableCell>
<TableCell>{fmtTime(r.started_at)}</TableCell>
<TableCell>{fmtTime(r.finished_at)}</TableCell>
<TableCell>{fmtDuration(r.started_at, r.finished_at)}</TableCell>
<TableCell>
<Button variant="link" size="sm">
<Link href={`/runs/${r.id}`}></Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

314
app/pipelines/new/page.tsx Normal file
View File

@ -0,0 +1,314 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
api,
type Connection,
type Pipeline,
type StreamConfig,
type StreamInfo,
} from "@/components/api";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
const MODES = [
{ value: "full-refresh", label: "full-refresh全量覆盖默认" },
{ value: "truncate", label: "truncate清空后写入" },
{ value: "incremental", label: "incremental增量" },
{ value: "snapshot", label: "snapshot快照" },
];
export default function NewPipelinePage() {
const router = useRouter();
const [connections, setConnections] = useState<Connection[]>([]);
const [connError, setConnError] = useState<string | null>(null);
const [name, setName] = useState("");
const [sourceId, setSourceId] = useState("");
const [targetId, setTargetId] = useState("");
const [mode, setMode] = useState("full-refresh");
const [schemaSync, setSchemaSync] = useState(true);
const [streamList, setStreamList] = useState<StreamInfo[] | null>(null);
const [streamsLoading, setStreamsLoading] = useState(false);
const [selected, setSelected] = useState<Record<string, StreamConfig>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
api<Connection[]>("/api/connections")
.then(setConnections)
.catch((e) => setConnError(e instanceof Error ? e.message : "加载失败"));
}, []);
// 切换源连接时同时清空已加载的表(在 onValueChange 里处理)
const selectedList = useMemo(() => Object.values(selected), [selected]);
async function loadStreams() {
if (!sourceId) return;
setStreamsLoading(true);
try {
const list = await api<StreamInfo[]>(
`/api/connections/${sourceId}/streams`
);
setStreamList(list);
if (list.length === 0) toast.info("该连接没有可同步的表");
} catch (e) {
toast.error(e instanceof Error ? e.message : "加载表失败");
} finally {
setStreamsLoading(false);
}
}
function toggleStream(s: StreamInfo, checked: boolean) {
setSelected((m) => {
const next = { ...m };
if (checked) next[s.name] = { name: s.name };
else delete next[s.name];
return next;
});
}
function selectAll(on: boolean) {
if (!streamList) return;
setSelected(
on
? Object.fromEntries(
streamList.map((s) => [s.name, selected[s.name] ?? { name: s.name }])
)
: {}
);
}
function setStreamField(
name: string,
field: "primary_key" | "update_key",
value: string
) {
setSelected((m) => ({
...m,
[name]: { ...m[name], name, [field]: value || undefined },
}));
}
async function submit() {
if (!name.trim()) return toast.error("请填写流水线名称");
if (!sourceId || !targetId) return toast.error("请选择源连接和目标连接");
if (sourceId === targetId) return toast.error("源连接和目标连接不能相同");
if (selectedList.length === 0) return toast.error("请至少选择一张表");
setSaving(true);
try {
const p = await api<Pipeline>("/api/pipelines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
source_conn_id: Number(sourceId),
target_conn_id: Number(targetId),
schema_sync: schemaSync ? 1 : 0,
mode,
streams: JSON.stringify(selectedList),
}),
});
toast.success("流水线已创建");
router.push(`/pipelines/${p.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "创建失败");
setSaving(false);
}
}
return (
<div className="flex max-w-3xl flex-col gap-6">
<h1 className="text-xl font-semibold">线</h1>
{connError ? (
<div className="text-sm text-red-500">{connError}</div>
) : null}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="p-name"></Label>
<Input
id="p-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:测试库 → 本地 dev"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label></Label>
<Select
value={sourceId}
onValueChange={(v) => {
setSourceId(String(v));
setStreamList(null);
setSelected({});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择源连接" />
</SelectTrigger>
<SelectContent>
{connections.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}{c.database}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label></Label>
<Select value={targetId} onValueChange={(v) => setTargetId(String(v))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择目标连接" />
</SelectTrigger>
<SelectContent>
{connections.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}{c.database}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-1.5">
<Label></Label>
<Select value={mode} onValueChange={(v) => setMode(String(v))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="flex flex-col gap-0.5">
<Label>Schema </Label>
<span className="text-xs text-muted-foreground">
Atlas schema//
</span>
</div>
<Switch checked={schemaSync} onCheckedChange={(c) => setSchemaSync(!!c)} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={loadStreams}
disabled={!sourceId || streamsLoading}
>
{streamsLoading ? "加载中…" : "加载表"}
</Button>
{streamList && streamList.length > 0 ? (
<>
<Button variant="ghost" size="sm" onClick={() => selectAll(true)}>
</Button>
<Button variant="ghost" size="sm" onClick={() => selectAll(false)}>
</Button>
<span className="text-xs text-muted-foreground">
{selectedList.length} / {streamList.length}
</span>
</>
) : null}
</div>
{streamList === null ? null : streamList.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="max-h-96 overflow-auto rounded-md border">
{streamList.map((s) => {
const checked = !!selected[s.name];
return (
<div
key={s.name}
className="flex items-center gap-3 border-b px-3 py-2 last:border-b-0"
>
<Checkbox
checked={checked}
onCheckedChange={(c) => toggleStream(s, !!c)}
/>
<span className="flex-1 font-mono text-sm">{s.name}</span>
{mode === "incremental" && checked ? (
<div className="flex items-center gap-2">
<Input
className="h-7 w-36 font-mono text-xs"
placeholder="primary_key"
value={selected[s.name].primary_key ?? ""}
onChange={(e) =>
setStreamField(s.name, "primary_key", e.target.value)
}
/>
<Input
className="h-7 w-36 font-mono text-xs"
placeholder="update_key"
value={selected[s.name].update_key ?? ""}
onChange={(e) =>
setStreamField(s.name, "update_key", e.target.value)
}
/>
</div>
) : null}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<div className="flex gap-2">
<Button onClick={submit} disabled={saving}>
{saving ? "创建中…" : "创建流水线"}
</Button>
<Button variant="outline" onClick={() => router.push("/pipelines")}>
</Button>
</div>
</div>
);
}

152
app/pipelines/page.tsx Normal file
View File

@ -0,0 +1,152 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { api, type Connection, type Pipeline } from "@/components/api";
import { fmtTime } from "@/components/format";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function PipelinesPage() {
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [connMap, setConnMap] = useState<Record<number, string>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<Pipeline | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const load = useCallback(() => {
Promise.all([
api<Pipeline[]>("/api/pipelines"),
api<Connection[]>("/api/connections"),
])
.then(([p, conns]) => {
setPipelines(p);
setConnMap(Object.fromEntries(conns.map((c) => [c.id, c.name])));
})
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
async function doDelete() {
if (!deleting) return;
setDeleteLoading(true);
try {
await api(`/api/pipelines/${deleting.id}`, { method: "DELETE" });
toast.success(`已删除流水线「${deleting.name}`);
setDeleting(null);
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
} finally {
setDeleteLoading(false);
}
}
const connName = (id: number) => connMap[id] ?? `#${id}`;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Pipelines</h1>
<Button>
<Link href="/pipelines/new" className="flex items-center gap-1">
<Plus className="size-4" />
线
</Link>
</Button>
</div>
{loading ? (
<div className="text-sm text-muted-foreground"></div>
) : error ? (
<div className="text-sm text-red-500">{error}</div>
) : pipelines.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
线线
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead>Mode</TableHead>
<TableHead>Schema </TableHead>
<TableHead></TableHead>
<TableHead className="w-40"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pipelines.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/pipelines/${p.id}`} className="hover:underline">
{p.name}
</Link>
</TableCell>
<TableCell>
{connName(p.source_conn_id)} {connName(p.target_conn_id)}
</TableCell>
<TableCell className="font-mono text-xs">{p.mode}</TableCell>
<TableCell>
{p.schema_sync ? (
<Badge variant="outline" className="border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400">
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtTime(p.updated_at)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="outline" size="sm">
<Link href={`/pipelines/${p.id}`}></Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleting(p)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title={`删除流水线「${deleting?.name ?? ""}」?`}
description="删除后其运行历史仍会保留。"
loading={deleteLoading}
onConfirm={doDelete}
/>
</div>
);
}

126
app/runs/[id]/page.tsx Normal file
View File

@ -0,0 +1,126 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
api,
type Pipeline,
type RunDetail,
} from "@/components/api";
import { LogViewer } from "@/components/log-viewer";
import { StatusBadge } from "@/components/status-badge";
import { fmtTime, fmtDuration } from "@/components/format";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function RunDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const [run, setRun] = useState<RunDetail | null>(null);
const [pipelineName, setPipelineName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
api<RunDetail>(`/api/runs/${id}`)
.then((r) => {
setRun(r);
api<Pipeline>(`/api/pipelines/${r.pipeline_id}`)
.then((p) => setPipelineName(p.name))
.catch(() => setPipelineName(null));
})
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
}, [id]);
useEffect(load, [load]);
if (loading) return <div className="text-sm text-muted-foreground"></div>;
if (error || !run)
return (
<div className="text-sm text-red-500">
{error ? `加载失败:${error}` : "运行记录不存在"}
</div>
);
const isActive = run.status === "running" || run.status === "queued";
return (
<div className="flex flex-col gap-6">
<h1 className="flex items-center gap-3 text-xl font-semibold">
#{run.id}
<StatusBadge status={run.status} />
</h1>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">线</dt>
<dd>
<Link
href={`/pipelines/${run.pipeline_id}`}
className="font-medium hover:underline"
>
{pipelineName ?? `#${run.pipeline_id}`}
</Link>
</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{fmtTime(run.started_at)}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{fmtTime(run.finished_at)}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd>{fmtDuration(run.started_at, run.finished_at)}</dd>
</div>
<div>
<dt className="text-muted-foreground"> PID</dt>
<dd className="font-mono">{run.pid ?? "-"}</dd>
</div>
</dl>
{run.error ? (
<div className="mt-4">
<div className="mb-1 text-sm text-muted-foreground"></div>
<pre className="whitespace-pre-wrap break-all rounded-md border border-red-500/40 bg-red-500/10 p-3 font-mono text-xs text-red-600 dark:text-red-400">
{run.error}
</pre>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{isActive ? "(实时)" : ""}</CardTitle>
</CardHeader>
<CardContent>
{isActive ? (
<LogViewer
runId={run.id}
live
className="h-[32rem]"
emptyText="等待日志输出…"
onDone={load}
/>
) : (
<LogViewer log={run.log ?? ""} className="h-[32rem]" />
)}
</CardContent>
</Card>
</div>
);
}

92
app/runs/page.tsx Normal file
View File

@ -0,0 +1,92 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { api, type RunWithPipeline } from "@/components/api";
import { StatusBadge } from "@/components/status-badge";
import { fmtTime, fmtDuration } from "@/components/format";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function RunsPage() {
const [runs, setRuns] = useState<RunWithPipeline[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
api<RunWithPipeline[]>("/api/runs")
.then(setRuns)
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
// 有运行中的任务时轮询刷新
const hasActive = runs.some(
(r) => r.status === "running" || r.status === "queued"
);
useEffect(() => {
if (!hasActive) return;
const t = setInterval(load, 4000);
return () => clearInterval(t);
}, [hasActive, load]);
return (
<div className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Runs</h1>
{loading ? (
<div className="text-sm text-muted-foreground"></div>
) : error ? (
<div className="text-sm text-red-500">{error}</div>
) : runs.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>线</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono">#{r.id}</TableCell>
<TableCell>
{r.pipeline_name ?? `(已删除 #${r.pipeline_id})`}
</TableCell>
<TableCell>
<StatusBadge status={r.status} />
</TableCell>
<TableCell>{fmtTime(r.started_at)}</TableCell>
<TableCell>{fmtDuration(r.started_at, r.finished_at)}</TableCell>
<TableCell>
<Button variant="link" size="sm">
<Link href={`/runs/${r.id}`}></Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

44
components/api.ts Normal file
View File

@ -0,0 +1,44 @@
import type {
Connection,
Pipeline,
Run,
RunWithPipeline,
StreamConfig,
} from "@/lib/db";
export type { Connection, Pipeline, Run, RunWithPipeline, StreamConfig };
export interface StreamInfo {
name: string;
columns?: string[];
}
export interface HealthResult {
sling: { path: string; version: string } | null;
atlas: { path: string; version: string } | null;
}
export interface RunDetail extends Run {
log?: string;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { cache: "no-store", ...init });
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
const msg =
typeof data?.error === "string"
? data.error
: `请求失败HTTP ${res.status}`;
throw new ApiError(msg, res.status);
}
return data as T;
}

View File

@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Database,
Workflow,
ScrollText,
} from "lucide-react";
import { cn } from "@/lib/utils";
const NAV = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/connections", label: "Connections", icon: Database },
{ href: "/pipelines", label: "Pipelines", icon: Workflow },
{ href: "/runs", label: "Runs", icon: ScrollText },
];
export function AppSidebar() {
const pathname = usePathname();
return (
<aside className="flex w-52 shrink-0 flex-col border-r bg-muted/30">
<div className="flex h-14 items-center border-b px-4">
<span className="font-mono text-sm font-semibold tracking-tight">
sling-ui
</span>
</div>
<nav className="flex flex-col gap-1 p-2">
{NAV.map(({ href, label, icon: Icon }) => {
const active =
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
active
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
<Icon className="size-4" />
{label}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmText?: string;
loading?: boolean;
onConfirm: () => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmText = "删除",
loading,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={loading}>
{loading ? "处理中…" : confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { api, type Connection } from "@/components/api";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ConnectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 传入则为编辑,否则为新建 */
initial?: Connection | null;
onSaved: () => void;
}
const EMPTY = {
name: "",
type: "mysql",
host: "127.0.0.1",
port: "3306",
user: "root",
password: "",
database: "",
};
export function ConnectionDialog({
open,
onOpenChange,
initial,
onSaved,
}: ConnectionDialogProps) {
const [form, setForm] = useState(() =>
initial
? {
name: initial.name,
type: initial.type,
host: initial.host,
port: String(initial.port),
user: initial.user,
password: initial.password,
database: initial.database,
}
: EMPTY
);
const [saving, setSaving] = useState(false);
const set = (k: keyof typeof EMPTY) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
async function submit() {
if (!form.name.trim() || !form.host.trim() || !form.database.trim()) {
toast.error("请填写名称、host 和 database");
return;
}
const port = Number(form.port) || 3306;
setSaving(true);
try {
const body = JSON.stringify({
name: form.name.trim(),
type: form.type,
host: form.host.trim(),
port,
user: form.user.trim(),
password: form.password,
database: form.database.trim(),
});
if (initial) {
await api(`/api/connections/${initial.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
});
toast.success("连接已更新");
} else {
await api("/api/connections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
toast.success("连接已创建");
}
onOpenChange(false);
onSaved();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{initial ? "编辑连接" : "新建连接"}</DialogTitle>
</DialogHeader>
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="conn-name"></Label>
<Input
id="conn-name"
value={form.name}
onChange={set("name")}
placeholder="例如:测试环境 MySQL"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label></Label>
<Select
value={form.type}
onValueChange={(v) => setForm((f) => ({ ...f, type: String(v) }))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mysql">mysql</SelectItem>
<SelectItem value="mariadb">mariadb</SelectItem>
<SelectItem value="tidb">tidb</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="conn-port"></Label>
<Input
id="conn-port"
type="number"
value={form.port}
onChange={set("port")}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="conn-host">Host</Label>
<Input id="conn-host" value={form.host} onChange={set("host")} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="conn-user"></Label>
<Input id="conn-user" value={form.user} onChange={set("user")} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="conn-password"></Label>
<Input
id="conn-password"
type="password"
value={form.password}
onChange={set("password")}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="conn-db"></Label>
<Input
id="conn-db"
value={form.database}
onChange={set("database")}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button onClick={submit} disabled={saving}>
{saving ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

32
components/format.ts Normal file
View File

@ -0,0 +1,32 @@
// SQLite datetime('now') 存的是 UTC "YYYY-MM-DD HH:MM:SS",直接 new Date 会被当作本地时间。
function toDate(s: string): Date {
const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(s)
? s.replace(" ", "T") + "Z"
: s;
return new Date(iso);
}
export function fmtTime(s: string | null | undefined): string {
if (!s) return "-";
const d = toDate(s);
return isNaN(d.getTime()) ? s : d.toLocaleString();
}
export function fmtDuration(
start: string | null | undefined,
end: string | null | undefined
): string {
if (!start) return "-";
const a = toDate(start).getTime();
if (isNaN(a)) return "-";
const b = end ? toDate(end).getTime() : Date.now();
if (isNaN(b)) return "-";
let sec = Math.max(0, Math.round((b - a) / 1000));
const h = Math.floor(sec / 3600);
sec -= h * 3600;
const m = Math.floor(sec / 60);
sec -= m * 60;
if (h > 0) return `${h}h ${m}m ${sec}s`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}

View File

@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import { TriangleAlert } from "lucide-react";
import { api, type HealthResult } from "@/components/api";
const INSTALL_HINTS: Record<string, string> = {
sling: "brew install slingdata-io/sling/sling或参考 https://docs.slingdata.io",
atlas: "brew install ariga/tap/atlas或参考 https://atlasgo.io",
};
export function HealthBanner() {
const [health, setHealth] = useState<HealthResult | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
api<HealthResult>("/api/health")
.then(setHealth)
.catch(() => setFailed(true));
}, []);
if (!health) {
if (!failed) return null;
return (
<div className="border-b border-amber-500/40 bg-amber-500/10 px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
CLI /api/health
</div>
);
}
const missing = (["sling", "atlas"] as const).filter((k) => !health[k]);
if (missing.length === 0) return null;
return (
<div className="flex items-start gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
<TriangleAlert className="mt-0.5 size-4 shrink-0" />
<div className="flex flex-col gap-0.5">
{missing.map((k) => (
<span key={k}>
<span className="font-mono font-medium">{k}</span> CLI
{k === "atlas" ? "schema 同步不可用)" : "(数据迁移不可用)"}
<span className="font-mono">{INSTALL_HINTS[k]}</span>
</span>
))}
</div>
</div>
);
}

129
components/log-viewer.tsx Normal file
View File

@ -0,0 +1,129 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const LEVEL_CLASS: Record<string, string> = {
trace: "text-zinc-600",
debug: "text-zinc-500",
info: "text-sky-400",
warn: "text-amber-400",
warning: "text-amber-400",
error: "text-red-400",
fatal: "text-red-500",
};
function parseLine(line: string): { text: string; className: string } {
const t = line.trim();
if (t.startsWith("{")) {
try {
const o = JSON.parse(t) as Record<string, unknown>;
if (o && typeof o === "object" && typeof o.lvl === "string") {
return {
text: typeof o.msg === "string" ? o.msg : t,
className: LEVEL_CLASS[o.lvl.toLowerCase()] ?? "text-zinc-300",
};
}
} catch {
// 不是合法 JSON原样显示
}
}
return { text: line, className: "text-zinc-300" };
}
interface LogViewerProps {
/** SSE 模式:订阅 /api/runs/[runId]/events。runId 变化时请通过 key 重挂载 */
runId?: number;
/** 静态模式:直接展示完整日志 */
log?: string;
live?: boolean;
className?: string;
emptyText?: string;
/** SSE 收到 done 事件时回调 */
onDone?: () => void;
}
export function LogViewer({
runId,
log,
live,
className,
emptyText = "暂无日志",
onDone,
}: LogViewerProps) {
const isLive = live && runId != null;
const [sseLines, setSseLines] = useState<string[]>([]);
const boxRef = useRef<HTMLDivElement>(null);
const stickRef = useRef(true);
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
useEffect(() => {
if (!isLive) return;
const es = new EventSource(`/api/runs/${runId}/events`);
es.onmessage = (e) => {
try {
const d = JSON.parse(e.data) as { line?: unknown };
setSseLines((prev) => [
...prev,
typeof d.line === "string" ? d.line : String(e.data),
]);
} catch {
setSseLines((prev) => [...prev, String(e.data)]);
}
};
es.addEventListener("done", () => {
es.close();
onDoneRef.current?.();
});
es.onerror = () => es.close();
return () => es.close();
}, [isLive, runId]);
const lines = useMemo(() => {
if (isLive) return sseLines;
if (log == null || log === "") return [];
return log.replace(/\n+$/, "").split("\n");
}, [isLive, sseLines, log]);
useEffect(() => {
const el = boxRef.current;
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
}, [lines]);
return (
<div
ref={boxRef}
onScroll={() => {
const el = boxRef.current;
if (!el) return;
// 用户上翻时暂停自动滚动,回到底部附近时恢复
stickRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className={cn(
"h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 p-3 font-mono text-xs leading-5",
className
)}
>
{lines.length === 0 ? (
<div className="text-zinc-500">{emptyText}</div>
) : (
lines.map((line, i) => {
const { text, className: lineCls } = parseLine(line);
return (
<div
key={i}
className={cn("whitespace-pre-wrap break-all", lineCls)}
>
{text || " "}
</div>
);
})
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
const CONFIG: Record<string, { label: string; className: string }> = {
running: {
label: "运行中",
className: "border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400",
},
success: {
label: "成功",
className:
"border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400",
},
failed: {
label: "失败",
className: "border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400",
},
queued: {
label: "排队中",
className:
"border-zinc-500/40 bg-zinc-500/10 text-zinc-600 dark:text-zinc-400",
},
};
export function StatusBadge({ status }: { status: string }) {
const c = CONFIG[status] ?? {
label: status,
className: "border-border text-muted-foreground",
};
return (
<Badge variant="outline" className={cn(c.className)}>
{c.label}
</Badge>
);
}

52
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

58
components/ui/button.tsx Normal file
View File

@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
components/ui/card.tsx Normal file
View File

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

160
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

20
components/ui/input.tsx Normal file
View File

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

20
components/ui/label.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

201
components/ui/select.tsx Normal file
View File

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

49
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

32
components/ui/switch.tsx Normal file
View File

@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

82
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

89
lib/atlas.ts Normal file
View File

@ -0,0 +1,89 @@
import { execFile } from "child_process";
import { Connection, getConnection } from "./db";
import { connectionUrl, execSql } from "./sling";
import { resolveBinary } from "./binaries";
// Atlas needs an empty scratch database ("dev database") to plan diffs.
// The usual `docker://mysql/8` dev-url spins up a throwaway container, but
// its dial to host.docker.internal is broken on some Docker runtimes (e.g.
// OrbStack: connections establish, then the handshake EOFs). Use a dedicated
// scratch schema on the target server instead — no Docker needed.
const DEV_SCHEMA = "sling_ui_atlas_dev";
function devDatabaseUrl(tgt: Connection): string {
const user = encodeURIComponent(tgt.user);
const pass = encodeURIComponent(tgt.password);
return `mysql://${user}:${pass}@${tgt.host}:${tgt.port}/${DEV_SCHEMA}`;
}
// Create the scratch schema if missing and return its URL. Atlas keeps it
// clean after each run.
export function prepareDevDatabase(tgt: Connection): string {
execSql(tgt, `CREATE DATABASE IF NOT EXISTS \`${DEV_SCHEMA}\``);
return devDatabaseUrl(tgt);
}
function baseApplyArgs(srcUrl: string, tgtUrl: string, devUrl: string): string[] {
return [
"schema",
"apply",
"--url",
tgtUrl,
"--to",
srcUrl,
"--dev-url",
devUrl,
];
}
export function buildApplyArgs(
srcUrl: string,
tgtUrl: string,
devUrl: string
): string[] {
return [...baseApplyArgs(srcUrl, tgtUrl, devUrl), "--auto-approve"];
}
// atlas rejects --dry-run combined with --auto-approve.
export function buildDryRunArgs(
srcUrl: string,
tgtUrl: string,
devUrl: string
): string[] {
return [...baseApplyArgs(srcUrl, tgtUrl, devUrl), "--dry-run"];
}
function atlasBin(): string {
return resolveBinary("atlas") ?? "atlas";
}
export function schemaDiff(
sourceConnId: number,
targetConnId: number
): Promise<{ ok: boolean; sql: string }> {
const src = getConnection(sourceConnId);
const tgt = getConnection(targetConnId);
if (!src || !tgt) throw new Error("connection not found");
let devUrl: string;
try {
devUrl = prepareDevDatabase(tgt);
} catch (e) {
const err = e as { stdout?: string; stderr?: string; message?: string };
return Promise.resolve({
ok: false,
sql: [err.stdout, err.stderr, err.message].filter(Boolean).join("\n"),
});
}
const args = buildDryRunArgs(connectionUrl(src), connectionUrl(tgt), devUrl);
return new Promise((resolve) => {
execFile(
/* turbopackIgnore: true */ atlasBin(),
args,
{ timeout: 120000, maxBuffer: 32 * 1024 * 1024 },
(error, stdout, stderr) => {
const sql = [stdout, stderr].filter(Boolean).join("\n");
resolve({ ok: !error, sql });
}
);
});
}

54
lib/binaries.ts Normal file
View File

@ -0,0 +1,54 @@
import { execSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
export type BinaryName = "sling" | "atlas";
export interface BinaryInfo {
path: string;
version: string;
}
export function resolveBinary(name: BinaryName): string | null {
try {
const out = execSync(`which ${name}`, {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
})
.trim()
.split("\n")[0];
if (out) return out;
} catch {
// not on PATH
}
if (name === "sling") {
const fallback = path.join(os.homedir(), ".sling", "bin", "sling");
if (fs.existsSync(fallback)) return fallback;
}
return null;
}
function probe(name: BinaryName): BinaryInfo | null {
const binPath = resolveBinary(name);
if (!binPath) return null;
let version = "";
try {
version = execSync(
name === "atlas" ? `"${binPath}" version` : `"${binPath}" --version`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
)
.trim()
.split("\n")[0];
} catch {
// version probe failed; still report the path
}
return { path: binPath, version };
}
export function checkBinaries(): {
sling: BinaryInfo | null;
atlas: BinaryInfo | null;
} {
return { sling: probe("sling"), atlas: probe("atlas") };
}

283
lib/db.ts Normal file
View File

@ -0,0 +1,283 @@
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
export const DATA_DIR = path.join(process.cwd(), "data");
const DB_PATH = path.join(DATA_DIR, "sling-ui.db");
export interface Connection {
id: number;
name: string;
type: string; // mysql | mariadb | tidb
host: string;
port: number;
user: string;
password: string;
database: string;
created_at: string;
}
export interface ConnectionInput {
name: string;
type: string;
host: string;
port: number;
user: string;
password: string;
database: string;
}
export interface StreamConfig {
name: string;
object?: string;
primary_key?: string;
update_key?: string;
select?: string;
}
export interface Pipeline {
id: number;
name: string;
source_conn_id: number;
target_conn_id: number;
schema_sync: number; // 1 | 0
mode: string;
streams: string; // JSON: StreamConfig[]
created_at: string;
updated_at: string;
}
export interface PipelineInput {
name: string;
source_conn_id: number;
target_conn_id: number;
schema_sync?: number;
mode?: string;
streams?: string;
}
export type RunStatus = "queued" | "running" | "success" | "failed";
export interface Run {
id: number;
pipeline_id: number;
status: RunStatus;
started_at: string | null;
finished_at: string | null;
log_path: string | null;
error: string | null;
pid: number | null;
}
function createDb(): Database.Database {
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
user TEXT NOT NULL,
password TEXT NOT NULL DEFAULT '',
database TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pipelines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
source_conn_id INTEGER NOT NULL REFERENCES connections(id),
target_conn_id INTEGER NOT NULL REFERENCES connections(id),
schema_sync INTEGER DEFAULT 1,
mode TEXT DEFAULT 'full-refresh',
streams TEXT NOT NULL DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pipeline_id INTEGER NOT NULL REFERENCES pipelines(id),
status TEXT NOT NULL DEFAULT 'queued',
started_at TEXT,
finished_at TEXT,
log_path TEXT,
error TEXT,
pid INTEGER
);
`);
return db;
}
// Keep a single connection across dev HMR reloads.
const globalForDb = globalThis as unknown as { __db?: Database.Database };
export const db: Database.Database =
globalForDb.__db ?? (globalForDb.__db = createDb());
// ---------- connections ----------
export function listConnections(): Connection[] {
return db
.prepare("SELECT * FROM connections ORDER BY id")
.all() as Connection[];
}
export function getConnection(id: number): Connection | null {
return (
(db.prepare("SELECT * FROM connections WHERE id = ?").get(id) as
| Connection
| undefined) ?? null
);
}
export function createConnection(input: ConnectionInput): Connection {
const res = db
.prepare(
`INSERT INTO connections (name, type, host, port, user, password, database)
VALUES (@name, @type, @host, @port, @user, @password, @database)`
)
.run({
name: input.name,
type: input.type,
host: input.host,
port: input.port,
user: input.user,
password: input.password ?? "",
database: input.database,
});
return getConnection(Number(res.lastInsertRowid))!;
}
export function updateConnection(
id: number,
input: Partial<ConnectionInput>
): Connection | null {
const fields = ["name", "type", "host", "port", "user", "password", "database"].filter(
(k) => input[k as keyof ConnectionInput] !== undefined
);
if (fields.length === 0) return getConnection(id);
const sets = fields.map((f) => `${f} = @${f}`).join(", ");
db.prepare(`UPDATE connections SET ${sets} WHERE id = @id`).run({
...input,
id,
});
return getConnection(id);
}
export function deleteConnection(id: number): boolean {
return db.prepare("DELETE FROM connections WHERE id = ?").run(id).changes > 0;
}
// ---------- pipelines ----------
export function listPipelines(): Pipeline[] {
return db.prepare("SELECT * FROM pipelines ORDER BY id").all() as Pipeline[];
}
export function getPipeline(id: number): Pipeline | null {
return (
(db.prepare("SELECT * FROM pipelines WHERE id = ?").get(id) as
| Pipeline
| undefined) ?? null
);
}
export function createPipeline(input: PipelineInput): Pipeline {
const res = db
.prepare(
`INSERT INTO pipelines (name, source_conn_id, target_conn_id, schema_sync, mode, streams)
VALUES (@name, @source_conn_id, @target_conn_id, @schema_sync, @mode, @streams)`
)
.run({
name: input.name,
source_conn_id: input.source_conn_id,
target_conn_id: input.target_conn_id,
schema_sync: input.schema_sync ?? 1,
mode: input.mode ?? "full-refresh",
streams: input.streams ?? "[]",
});
return getPipeline(Number(res.lastInsertRowid))!;
}
export function updatePipeline(
id: number,
input: Partial<PipelineInput>
): Pipeline | null {
const fields = [
"name",
"source_conn_id",
"target_conn_id",
"schema_sync",
"mode",
"streams",
].filter((k) => input[k as keyof PipelineInput] !== undefined);
if (fields.length === 0) return getPipeline(id);
const sets = fields.map((f) => `${f} = @${f}`).join(", ");
db.prepare(
`UPDATE pipelines SET ${sets}, updated_at = datetime('now') WHERE id = @id`
).run({ ...input, id });
return getPipeline(id);
}
export function deletePipeline(id: number): boolean {
return db.prepare("DELETE FROM pipelines WHERE id = ?").run(id).changes > 0;
}
// ---------- runs ----------
export function createRun(input: {
pipeline_id: number;
status?: RunStatus;
started_at?: string;
log_path?: string;
pid?: number;
}): Run {
const res = db
.prepare(
`INSERT INTO runs (pipeline_id, status, started_at, log_path, pid)
VALUES (@pipeline_id, @status, @started_at, @log_path, @pid)`
)
.run({
pipeline_id: input.pipeline_id,
status: input.status ?? "running",
started_at: input.started_at ?? null,
log_path: input.log_path ?? null,
pid: input.pid ?? null,
});
return getRun(Number(res.lastInsertRowid))!;
}
export function getRun(id: number): Run | null {
return (
(db.prepare("SELECT * FROM runs WHERE id = ?").get(id) as Run | undefined) ??
null
);
}
export function updateRunStatus(
id: number,
patch: Partial<Pick<Run, "status" | "finished_at" | "error" | "pid" | "log_path">>
): Run | null {
const fields = ["status", "finished_at", "error", "pid", "log_path"].filter(
(k) => patch[k as keyof typeof patch] !== undefined
);
if (fields.length === 0) return getRun(id);
const sets = fields.map((f) => `${f} = @${f}`).join(", ");
db.prepare(`UPDATE runs SET ${sets} WHERE id = @id`).run({ ...patch, id });
return getRun(id);
}
export interface RunWithPipeline extends Run {
pipeline_name: string | null;
}
export function listRuns(): RunWithPipeline[] {
return db
.prepare(
`SELECT r.*, p.name AS pipeline_name
FROM runs r LEFT JOIN pipelines p ON p.id = r.pipeline_id
ORDER BY r.id DESC`
)
.all() as RunWithPipeline[];
}

218
lib/jobs.ts Normal file
View File

@ -0,0 +1,218 @@
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import {
DATA_DIR,
Pipeline,
Run,
createRun,
getConnection,
getPipeline,
getRun,
updateRunStatus,
} from "./db";
import { SLING_HOME, buildReplicationYaml, connectionUrl } from "./sling";
import { buildApplyArgs, prepareDevDatabase } from "./atlas";
import { resolveBinary } from "./binaries";
export interface JobEvent {
line: string;
ts: number;
}
export type LineListener = (ev: JobEvent) => void;
export type DoneListener = (status: string) => void;
// Mask credentials in echoed command lines (e.g. mysql://user:pass@host/db)
function maskSecret(arg: string): string {
return arg.replace(/(\w[\w-]*:\/\/[^:/\s]+:)[^@\s]+(@)/, "$1****$2");
}
export class AlreadyRunningError extends Error {
constructor(pipelineId: number) {
super(`pipeline ${pipelineId} already has a running run`);
this.name = "AlreadyRunningError";
}
}
interface ActiveRun {
runId: number;
pipelineId: number;
logPath: string;
buffer: JobEvent[];
tail: string[]; // last lines, used for the error summary
listeners: Set<LineListener>;
doneListeners: Set<DoneListener>;
}
const TAIL_LIMIT = 20;
class JobManager {
private active = new Map<number, ActiveRun>(); // runId -> state
private byPipeline = new Map<number, number>(); // pipelineId -> runId
isRunning(pipelineId: number): boolean {
return this.byPipeline.has(pipelineId);
}
startRun(pipelineId: number): Run {
const pipeline = getPipeline(pipelineId);
if (!pipeline) throw new Error(`pipeline ${pipelineId} not found`);
if (this.isRunning(pipelineId)) throw new AlreadyRunningError(pipelineId);
const run = createRun({
pipeline_id: pipelineId,
status: "running",
started_at: new Date().toISOString(),
});
const runsDir = path.join(DATA_DIR, "runs");
fs.mkdirSync(runsDir, { recursive: true });
const logPath = path.join(runsDir, `${run.id}.log`);
updateRunStatus(run.id, { log_path: logPath });
const state: ActiveRun = {
runId: run.id,
pipelineId,
logPath,
buffer: [],
tail: [],
listeners: new Set(),
doneListeners: new Set(),
};
this.active.set(run.id, state);
this.byPipeline.set(pipelineId, run.id);
void this.execute(pipeline, state);
return getRun(run.id)!;
}
// Subscribe to live output. Replays the in-memory buffer first, then pushes
// new lines. Returns an unsubscribe function, or null if the run is not
// active in this process (e.g. after a dev HMR reload).
subscribe(
runId: number,
onLine: LineListener,
onDone?: DoneListener
): (() => void) | null {
const state = this.active.get(runId);
if (!state) return null;
for (const ev of state.buffer) onLine(ev);
state.listeners.add(onLine);
if (onDone) state.doneListeners.add(onDone);
return () => {
state.listeners.delete(onLine);
if (onDone) state.doneListeners.delete(onDone);
};
}
private emit(state: ActiveRun, line: string) {
const ev: JobEvent = { line, ts: Date.now() };
state.buffer.push(ev);
state.tail.push(line);
if (state.tail.length > TAIL_LIMIT) state.tail.shift();
try {
fs.appendFileSync(state.logPath, line + "\n");
} catch {
// keep streaming to subscribers even if the log file fails
}
for (const l of state.listeners) l(ev);
}
private finish(state: ActiveRun, status: "success" | "failed", error: string | null) {
updateRunStatus(state.runId, {
status,
error,
finished_at: new Date().toISOString(),
});
this.active.delete(state.runId);
this.byPipeline.delete(state.pipelineId);
for (const cb of state.doneListeners) cb(status);
state.listeners.clear();
state.doneListeners.clear();
}
private async execute(pipeline: Pipeline, state: ActiveRun) {
try {
if (pipeline.schema_sync) {
const src = getConnection(pipeline.source_conn_id);
const tgt = getConnection(pipeline.target_conn_id);
if (!src || !tgt) throw new Error("pipeline connections not found");
const devUrl = prepareDevDatabase(tgt);
const code = await this.runProcess(
state,
resolveBinary("atlas") ?? "atlas",
buildApplyArgs(connectionUrl(src), connectionUrl(tgt), devUrl),
{}
);
if (code !== 0) {
throw new Error(
`atlas schema apply exited with code ${code}\n${state.tail.join("\n")}`
);
}
}
const yamlPath = buildReplicationYaml(pipeline, state.runId);
const code = await this.runProcess(
state,
resolveBinary("sling") ?? "sling",
["run", "-r", yamlPath, "--home-dir", SLING_HOME],
{ SLING_LOGGING: "JSON", SLING_DISABLE_TELEMETRY: "1" }
);
if (code === 0) {
this.finish(state, "success", null);
} else {
this.finish(
state,
"failed",
`sling exited with code ${code}\n${state.tail.join("\n")}`
);
}
} catch (err) {
this.emit(state, `[error] ${(err as Error).message}`);
this.finish(state, "failed", (err as Error).message);
}
}
private runProcess(
state: ActiveRun,
bin: string,
args: string[],
extraEnv: Record<string, string>
): Promise<number> {
return new Promise((resolve, reject) => {
this.emit(state, `$ ${bin} ${args.map(maskSecret).join(" ")}`);
const child = spawn(bin, args, {
env: { ...process.env, ...extraEnv },
});
if (child.pid) {
updateRunStatus(state.runId, { pid: child.pid });
}
child.on("error", reject);
const hookStream = (stream: NodeJS.ReadableStream | null) => {
if (!stream) return;
let buf = "";
stream.on("data", (chunk: Buffer) => {
buf += chunk.toString();
let idx: number;
while ((idx = buf.indexOf("\n")) >= 0) {
this.emit(state, buf.slice(0, idx));
buf = buf.slice(idx + 1);
}
});
stream.on("end", () => {
if (buf) this.emit(state, buf);
});
};
hookStream(child.stdout);
hookStream(child.stderr);
child.on("close", (code) => resolve(code ?? 1));
});
}
}
// Singleton surviving dev HMR reloads.
const g = globalThis as unknown as { __jobManager?: JobManager };
export const jobManager: JobManager =
g.__jobManager ?? (g.__jobManager = new JobManager());

247
lib/sling.ts Normal file
View File

@ -0,0 +1,247 @@
import { execFileSync } from "child_process";
import fs from "fs";
import path from "path";
import { dump } from "js-yaml";
import {
Connection,
DATA_DIR,
Pipeline,
StreamConfig,
getConnection,
listConnections,
} from "./db";
import { resolveBinary } from "./binaries";
export const SLING_HOME = path.join(DATA_DIR, "sling-home");
export interface DiscoveredStream {
name: string;
columns?: string[];
}
// Build a mysql:// URL from a connection record, escaping credentials.
export function connectionUrl(conn: Connection): string {
const user = encodeURIComponent(conn.user);
const pass = encodeURIComponent(conn.password);
return `mysql://${user}:${pass}@${conn.host}:${conn.port}/${conn.database}`;
}
// Regenerate env.yaml from the connections table. Names are uppercased
// because sling treats connection names case-insensitively. Session-level
// foreign_key_checks=0 lets sling truncate/drop FK-referenced parent tables
// in any order (same approach as mysqldump); constraints stay defined.
export function syncEnvYaml(): string {
fs.mkdirSync(SLING_HOME, { recursive: true });
const connections: Record<string, { url: string }> = {};
for (const c of listConnections()) {
connections[c.name.toUpperCase()] = {
url: `${connectionUrl(c)}?foreign_key_checks=0`,
};
}
const file = path.join(SLING_HOME, "env.yaml");
fs.writeFileSync(file, dump({ connections }));
return file;
}
function slingBin(): string {
return resolveBinary("sling") ?? "sling";
}
export function testConnection(id: number): { ok: boolean; output: string } {
const conn = getConnection(id);
if (!conn) throw new Error(`connection ${id} not found`);
syncEnvYaml();
try {
const output = execFileSync(
/* turbopackIgnore: true */ slingBin(),
["conns", "test", conn.name, "--home-dir", SLING_HOME],
{ encoding: "utf8", timeout: 30000 }
);
return { ok: true, output };
} catch (e) {
const err = e as { stdout?: string; stderr?: string; message?: string };
return {
ok: false,
output: [err.stdout, err.stderr, err.message]
.filter(Boolean)
.join("\n"),
};
}
}
// `conns discover --output json --columns` emits a tabular payload:
// { fields: ["Database","Schema","Table","ID","Column","Native Type","General Type"],
// rows: [[db, schema, table, id, column, native, general], ...] }
// covering every schema on the server. Group rows by table, keep only the
// connection's own database, and collect column names in row order.
function fromFieldsRows(
d: Record<string, unknown>,
database: string
): DiscoveredStream[] | null {
if (!Array.isArray(d.fields) || !Array.isArray(d.rows)) return null;
const fields = d.fields.map((f) => String(f).toLowerCase());
// The "Database" column holds the connection's database for every row, so
// prefer "Schema" (the table's actual schema) for filtering.
const schemaIdx =
fields.findIndex((f) => f === "schema") >= 0
? fields.findIndex((f) => f === "schema")
: fields.findIndex((f) => f === "database");
const tableIdx = fields.findIndex((f) => f === "table");
const colIdx = fields.findIndex((f) => f === "column");
if (tableIdx < 0) return null;
const byTable = new Map<string, string[]>();
for (const row of d.rows) {
if (!Array.isArray(row)) continue;
const schema = schemaIdx >= 0 ? String(row[schemaIdx] ?? "") : "";
if (database && schema && schema !== database) continue;
const table = String(row[tableIdx] ?? "");
if (!table) continue;
let cols = byTable.get(table);
if (!cols) byTable.set(table, (cols = []));
if (colIdx >= 0 && row[colIdx] != null) cols.push(String(row[colIdx]));
}
return [...byTable.entries()].map(([name, columns]) => ({ name, columns }));
}
// Defensive fallbacks for other plausible shapes: string array, object array,
// { streams: [...] }, or a map of stream name -> { columns }.
function normalizeDiscover(
data: unknown,
database: string
): DiscoveredStream[] {
if (data && typeof data === "object" && !Array.isArray(data)) {
const tabular = fromFieldsRows(data as Record<string, unknown>, database);
if (tabular) return tabular;
}
const out: DiscoveredStream[] = [];
const toColumns = (raw: unknown): string[] | undefined => {
if (!Array.isArray(raw)) return undefined;
return raw.map((c) =>
typeof c === "string"
? c
: String((c as Record<string, unknown>)?.name ?? c)
);
};
const push = (item: unknown) => {
if (typeof item === "string") {
out.push({ name: item });
return;
}
if (item && typeof item === "object") {
const o = item as Record<string, unknown>;
const name = String(o.name ?? o.stream ?? o.table ?? "");
if (!name) return;
const columns = toColumns(o.columns);
out.push(columns ? { name, columns } : { name });
}
};
if (Array.isArray(data)) {
data.forEach(push);
} else if (data && typeof data === "object") {
const d = data as Record<string, unknown>;
if (Array.isArray(d.streams)) {
d.streams.forEach(push);
} else {
for (const [key, value] of Object.entries(d)) {
const columns = toColumns((value as Record<string, unknown>)?.columns);
out.push(columns ? { name: key, columns } : { name: key });
}
}
}
return out;
}
function parseJsonLoose(text: string): unknown {
try {
return JSON.parse(text);
} catch {
// stdout may carry log noise around the payload; slice from the first brace
const start = text.search(/[[{]/);
if (start >= 0) return JSON.parse(text.slice(start));
throw new Error("sling discover did not output JSON");
}
}
// Execute a SQL statement on a connection via `sling conns exec`.
export function execSql(conn: Connection, sql: string): void {
syncEnvYaml();
execFileSync(
/* turbopackIgnore: true */ slingBin(),
["conns", "exec", conn.name.toUpperCase(), sql, "--home-dir", SLING_HOME],
{ encoding: "utf8", timeout: 30000 }
);
}
export function discoverStreams(id: number): DiscoveredStream[] {
const conn = getConnection(id);
if (!conn) throw new Error(`connection ${id} not found`);
syncEnvYaml();
const output = execFileSync(
/* turbopackIgnore: true */ slingBin(),
[
"conns",
"discover",
conn.name,
"--output",
"json",
"--columns",
"--home-dir",
SLING_HOME,
],
{ encoding: "utf8", timeout: 60000, maxBuffer: 32 * 1024 * 1024 }
);
return normalizeDiscover(parseJsonLoose(output), conn.database);
}
// Render the pipeline into a sling replication.yaml for a given run.
export function buildReplicationYaml(
pipeline: Pipeline,
runId: number
): string {
const src = getConnection(pipeline.source_conn_id);
const tgt = getConnection(pipeline.target_conn_id);
if (!src || !tgt) throw new Error("pipeline connections not found");
syncEnvYaml();
const streams = JSON.parse(pipeline.streams || "[]") as StreamConfig[];
const streamsYaml: Record<string, Record<string, unknown> | null> = {};
for (const s of streams) {
const cfg: Record<string, unknown> = {};
if (s.object) cfg.object = s.object;
if (s.primary_key) cfg.primary_key = s.primary_key;
if (s.update_key) cfg.update_key = s.update_key;
if (s.select) cfg.select = s.select;
// sling requires database-qualified stream names (e.g. "mydb.users") to
// resolve source columns; bare table names fail with "did not find any
// columns".
const key = s.name.includes(".") ? s.name : `${src.database}.${s.name}`;
streamsYaml[key] = Object.keys(cfg).length > 0 ? cfg : null;
}
// With schema_sync on, Atlas owns the target schema (indexes, FKs, exact
// column types). sling's full-refresh would drop & recreate tables with
// inferred types and no constraints, so translate it to truncate, which
// keeps the Atlas-managed schema and reloads all rows.
const mode =
pipeline.schema_sync && (pipeline.mode || "full-refresh") === "full-refresh"
? "truncate"
: pipeline.mode || "full-refresh";
const doc = {
source: src.name.toUpperCase(),
target: tgt.name.toUpperCase(),
defaults: {
mode,
// sling also requires a schema-qualified target object name.
object: `${tgt.database}.{stream_table}`,
},
streams: streamsYaml,
env: { SLING_THREADS: 4 },
};
const dir = path.join(DATA_DIR, "replications");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${runId}.yaml`);
fs.writeFileSync(file, dump(doc));
return file;
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ serverExternalPackages: ["better-sqlite3"],
}; };
export default nextConfig; export default nextConfig;

View File

@ -9,12 +9,25 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0",
"better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"js-yaml": "^5.2.3",
"lucide-react": "^1.30.0",
"next": "16.3.0", "next": "16.3.0",
"next-themes": "^0.4.6",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"shadcn": "^4.16.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

File diff suppressed because it is too large Load Diff