From 6ff64d4c806f233e833b9f19b557634f83df4fc8 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 11 Mar 2026 16:28:37 +0800 Subject: [PATCH] Init: First Commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一条提交 --- .gitignore | 6 + .prettierrc | 4 + package.json | 13 + packages/client/index.html | 12 + packages/client/package.json | 29 + packages/client/postcss.config.js | 6 + packages/client/src/App.tsx | 20 + packages/client/src/components/Layout.tsx | 21 + packages/client/src/index.css | 3 + packages/client/src/main.tsx | 10 + packages/client/src/pages/BuildDetail.tsx | 112 + packages/client/src/pages/ProjectDetail.tsx | 282 ++ packages/client/src/pages/Projects.tsx | 119 + packages/client/tailwind.config.js | 8 + packages/client/tsconfig.json | 21 + packages/client/tsconfig.tsbuildinfo | 1 + packages/client/vite.config.ts | 21 + packages/server/package.json | 15 + packages/server/src/db/index.js | 21 + packages/server/src/db/schema.sql | 18 + packages/server/src/index.js | 48 + packages/server/src/routes/builds.js | 69 + packages/server/src/routes/projects.js | 73 + packages/server/src/routes/webhook.js | 48 + packages/server/src/services/executor.js | 33 + packages/server/src/services/queue.js | 9 + pnpm-lock.yaml | 2718 +++++++++++++++++++ pnpm-workspace.yaml | 2 + 28 files changed, 3742 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 package.json create mode 100644 packages/client/index.html create mode 100644 packages/client/package.json create mode 100644 packages/client/postcss.config.js create mode 100644 packages/client/src/App.tsx create mode 100644 packages/client/src/components/Layout.tsx create mode 100644 packages/client/src/index.css create mode 100644 packages/client/src/main.tsx create mode 100644 packages/client/src/pages/BuildDetail.tsx create mode 100644 packages/client/src/pages/ProjectDetail.tsx create mode 100644 packages/client/src/pages/Projects.tsx create mode 100644 packages/client/tailwind.config.js create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsconfig.tsbuildinfo create mode 100644 packages/client/vite.config.ts create mode 100644 packages/server/package.json create mode 100644 packages/server/src/db/index.js create mode 100644 packages/server/src/db/schema.sql create mode 100644 packages/server/src/index.js create mode 100644 packages/server/src/routes/builds.js create mode 100644 packages/server/src/routes/projects.js create mode 100644 packages/server/src/routes/webhook.js create mode 100644 packages/server/src/services/executor.js create mode 100644 packages/server/src/services/queue.js create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffd94cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +packages/server/public/ +packages/server/data/ +dist/ +.env +*.db diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5419b00 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 200, + "tabWidth": 2 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..74e9ae7 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "paul-cicd", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "concurrently \"pnpm --filter server dev\" \"pnpm --filter client dev\"", + "build": "pnpm --filter client build", + "start": "pnpm --filter server start" + }, + "devDependencies": { + "concurrently": "^9.0.0" + } +} diff --git a/packages/client/index.html b/packages/client/index.html new file mode 100644 index 0000000..c7570a4 --- /dev/null +++ b/packages/client/index.html @@ -0,0 +1,12 @@ + + + + + + Paul CI/CD + + +
+ + + diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..2f1dfc1 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,29 @@ +{ + "name": "client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "lucide-react": "^0.469.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/packages/client/postcss.config.js b/packages/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/packages/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx new file mode 100644 index 0000000..d03fd1e --- /dev/null +++ b/packages/client/src/App.tsx @@ -0,0 +1,20 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import Projects from "./pages/Projects"; +import ProjectDetail from "./pages/ProjectDetail"; +import BuildDetail from "./pages/BuildDetail"; +import Layout from "./components/Layout"; + +export default function App() { + return ( + + + }> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/packages/client/src/components/Layout.tsx b/packages/client/src/components/Layout.tsx new file mode 100644 index 0000000..ad6a13b --- /dev/null +++ b/packages/client/src/components/Layout.tsx @@ -0,0 +1,21 @@ +import { Outlet, NavLink } from "react-router-dom"; +import { GitBranch } from "lucide-react"; + +export default function Layout() { + return ( +
+
+ + + Paul CI/CD + +
+
+ +
+
+ ); +} diff --git a/packages/client/src/index.css b/packages/client/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/packages/client/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx new file mode 100644 index 0000000..eff7ccc --- /dev/null +++ b/packages/client/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/client/src/pages/BuildDetail.tsx b/packages/client/src/pages/BuildDetail.tsx new file mode 100644 index 0000000..e1da058 --- /dev/null +++ b/packages/client/src/pages/BuildDetail.tsx @@ -0,0 +1,112 @@ +import { useState, useEffect, useRef } from "react"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; + +interface Build { + id: number; + project_id: number; + status: string; + trigger_ref: string; + started_at: number; + finished_at: number; +} + +const STATUS_COLOR: Record = { + success: "text-green-400", + failed: "text-red-400", + running: "text-yellow-400", + pending: "text-gray-400", +}; + +export default function BuildDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [build, setBuild] = useState(null); + const [log, setLog] = useState(""); + const [status, setStatus] = useState("pending"); + const logRef = useRef(null); + + useEffect(() => { + fetch(`/api/builds/${id}`) + .then((r) => r.json()) + .then((b) => { + setBuild(b); + setStatus(b.status); + }); + }, [id]); + + useEffect(() => { + const es = new EventSource(`/api/builds/${id}/log`); + + es.onmessage = (e) => { + const data = JSON.parse(e.data); + if (data.log) setLog(data.log); + if (data.chunk) setLog((prev) => prev + data.chunk); + if (data.status) setStatus(data.status); + if (data.done) es.close(); + }; + + es.onerror = () => es.close(); + + return () => es.close(); + }, [id]); + + // Auto-scroll to bottom + useEffect(() => { + if (logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [log]); + + const duration = + build?.started_at && build?.finished_at + ? `${build.finished_at - build.started_at}s` + : null; + + return ( +
+
+ +

Build #{id}

+ + {status} + +
+ + {build && ( +
+ + ref:{" "} + + {build.trigger_ref || "—"} + + + {duration && ( + + duration: {duration} + + )} +
+ )} + +
+        {log || (status === "pending" ? "Waiting to start..." : "No output.")}
+        {status === "running" && }
+      
+
+ ); +} diff --git a/packages/client/src/pages/ProjectDetail.tsx b/packages/client/src/pages/ProjectDetail.tsx new file mode 100644 index 0000000..19e3a79 --- /dev/null +++ b/packages/client/src/pages/ProjectDetail.tsx @@ -0,0 +1,282 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { + Copy, + RefreshCw, + Save, + ArrowLeft, + Play, + Eye, + EyeOff, +} from "lucide-react"; + +interface Project { + id: number; + name: string; + webhook_secret: string; + trigger_branch: string; + shell_script: string; +} + +interface Build { + id: number; + status: string; + trigger_ref: string; + started_at: number; + finished_at: number; +} + +const STATUS_COLOR: Record = { + success: "text-green-400", + failed: "text-red-400", + running: "text-yellow-400", + pending: "text-gray-400", +}; + +export default function ProjectDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [project, setProject] = useState(null); + const [builds, setBuilds] = useState([]); + const [form, setForm] = useState({ + name: "", + trigger_branch: "", + shell_script: "", + }); + const [saved, setSaved] = useState(false); + const [copied, setCopied] = useState(false); + const [secretVisible, setSecretVisible] = useState(false); + const [triggering, setTriggering] = useState(false); + + const load = async () => { + const [p, b] = await Promise.all([ + fetch(`/api/projects/${id}`).then((r) => r.json()), + fetch(`/api/projects/${id}/builds`).then((r) => r.json()), + ]); + setProject(p); + setBuilds(b); + setForm({ + name: p.name, + trigger_branch: p.trigger_branch, + shell_script: p.shell_script, + }); + }; + + useEffect(() => { + load(); + }, [id]); + + const save = async () => { + await fetch(`/api/projects/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + load(); + }; + + const regenerateSecret = async () => { + if ( + !confirm("Regenerate webhook secret? The old secret will stop working.") + ) + return; + await fetch(`/api/projects/${id}/regenerate-secret`, { method: "POST" }); + load(); + }; + + const copySecret = () => { + navigator.clipboard.writeText(project?.webhook_secret ?? ""); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const triggerBuild = async () => { + setTriggering(true); + const res = await fetch(`/api/projects/${id}/trigger`, { method: "POST" }); + const data = await res.json(); + setTriggering(false); + if (data.buildId) navigate(`/builds/${data.buildId}`); + load(); + }; + + const webhookUrl = `${window.location.origin}/api/webhook/${id}`; + + if (!project) return

Loading...

; + + return ( +
+
+ +

{project.name}

+
+ + {/* Config */} +
+

+ Configuration +

+ +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500" + /> +
+ +
+ + + setForm((f) => ({ ...f, trigger_branch: e.target.value })) + } + className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500" + /> +
+ +
+ +