commit
6ff64d4c80
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
packages/server/public/
|
||||||
|
packages/server/data/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"printWidth": 200,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Paul CI/CD</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||||
|
<Route path="/projects" element={<Projects />} />
|
||||||
|
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||||
|
<Route path="/builds/:id" element={<BuildDetail />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Outlet, NavLink } from "react-router-dom";
|
||||||
|
import { GitBranch } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 text-gray-100">
|
||||||
|
<header className="border-b border-gray-800 px-6 py-4 flex items-center gap-3">
|
||||||
|
<GitBranch className="w-5 h-5 text-blue-400" />
|
||||||
|
<NavLink
|
||||||
|
to="/projects"
|
||||||
|
className="text-lg font-semibold tracking-tight"
|
||||||
|
>
|
||||||
|
Paul CI/CD
|
||||||
|
</NavLink>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -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<string, string> = {
|
||||||
|
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<Build | null>(null);
|
||||||
|
const [log, setLog] = useState("");
|
||||||
|
const [status, setStatus] = useState("pending");
|
||||||
|
const logRef = useRef<HTMLPreElement>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
build
|
||||||
|
? navigate(`/projects/${build.project_id}`)
|
||||||
|
: navigate("/projects")
|
||||||
|
}
|
||||||
|
className="text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-semibold">Build #{id}</h1>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${STATUS_COLOR[status] ?? "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{build && (
|
||||||
|
<div className="flex gap-6 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
ref:{" "}
|
||||||
|
<span className="text-gray-300 font-mono">
|
||||||
|
{build.trigger_ref || "—"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{duration && (
|
||||||
|
<span>
|
||||||
|
duration: <span className="text-gray-300">{duration}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<pre
|
||||||
|
ref={logRef}
|
||||||
|
className="bg-gray-900 border border-gray-800 rounded-lg p-4 text-xs font-mono text-gray-300 overflow-auto max-h-[70vh] whitespace-pre-wrap break-words"
|
||||||
|
>
|
||||||
|
{log || (status === "pending" ? "Waiting to start..." : "No output.")}
|
||||||
|
{status === "running" && <span className="animate-pulse">▌</span>}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string, string> = {
|
||||||
|
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<Project | null>(null);
|
||||||
|
const [builds, setBuilds] = useState<Build[]>([]);
|
||||||
|
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 <p className="text-gray-500 text-sm">Loading...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/projects")}
|
||||||
|
className="text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-semibold">{project.name}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config */}
|
||||||
|
<section className="bg-gray-900 border border-gray-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Configuration
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-gray-400">Project Name</label>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-gray-400">Trigger Branch</label>
|
||||||
|
<input
|
||||||
|
value={form.trigger_branch}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-gray-400">Shell Script</label>
|
||||||
|
<textarea
|
||||||
|
value={form.shell_script}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, shell_script: e.target.value }))
|
||||||
|
}
|
||||||
|
rows={8}
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm font-mono outline-none focus:border-blue-500 resize-y"
|
||||||
|
placeholder="cd /path/to/project git pull pnpm install pnpm build pm2 restart app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saved ? "Saved!" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={triggerBuild}
|
||||||
|
disabled={triggering}
|
||||||
|
className="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
{triggering ? "Triggering..." : "Run Now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Webhook */}
|
||||||
|
<section className="bg-gray-900 border border-gray-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Webhook
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-gray-400">Webhook URL</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={webhookUrl}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm font-mono text-gray-300 outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(webhookUrl);
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-200 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-gray-400">Secret</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
type={secretVisible ? "text" : "password"}
|
||||||
|
value={project.webhook_secret}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm font-mono text-gray-300 outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setSecretVisible((v) => !v)}
|
||||||
|
className="text-gray-400 hover:text-gray-200 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{secretVisible ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={copySecret}
|
||||||
|
className="text-gray-400 hover:text-gray-200 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<span className="text-xs px-1">Copied</span>
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={regenerateSecret}
|
||||||
|
className="text-gray-400 hover:text-yellow-400 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Builds */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Recent Builds
|
||||||
|
</h2>
|
||||||
|
{builds.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
No builds yet. Push to the trigger branch to start one.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{builds.map((b) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
<Link
|
||||||
|
to={`/builds/${b.id}`}
|
||||||
|
className="flex items-center justify-between bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-4 py-3 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Play className="w-3.5 h-3.5 text-gray-500" />
|
||||||
|
<span className="text-sm font-mono text-gray-300">
|
||||||
|
#{b.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{b.trigger_ref}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${STATUS_COLOR[b.status] ?? "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
{b.status}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Plus, Trash2, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
trigger_branch: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Projects() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
|
fetch("/api/projects")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setProjects);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
await fetch("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newName.trim() }),
|
||||||
|
});
|
||||||
|
setNewName("");
|
||||||
|
setCreating(false);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (e: React.MouseEvent, id: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm("Delete this project and all its builds?")) return;
|
||||||
|
await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-xl font-semibold">Projects</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 text-white text-sm px-3 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className="mb-4 flex gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") create();
|
||||||
|
if (e.key === "Escape") setCreating(false);
|
||||||
|
}}
|
||||||
|
placeholder="Project name"
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={create}
|
||||||
|
className="bg-blue-600 hover:bg-blue-500 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-200 text-sm px-3 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects.length === 0 && !creating ? (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
No projects yet. Create one to get started.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => navigate(`/projects/${p.id}`)}
|
||||||
|
className="flex items-center justify-between bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-4 py-3 cursor-pointer transition-colors group"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{p.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
branch: {p.trigger_branch}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => remove(e, p.id)}
|
||||||
|
className="text-gray-600 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/pages/builddetail.tsx","./src/pages/projectdetail.tsx","./src/pages/projects.tsx"],"version":"5.9.3"}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "../server/public",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/static": "^8.0.0",
|
||||||
|
"better-sqlite3": "^11.0.0",
|
||||||
|
"fastify": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const DB_PATH = join(__dirname, "../../data/cicd.db");
|
||||||
|
|
||||||
|
// ensure data dir exists
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
mkdirSync(join(__dirname, "../../data"), { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
export default db;
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
webhook_secret TEXT NOT NULL,
|
||||||
|
trigger_branch TEXT NOT NULL DEFAULT 'main',
|
||||||
|
shell_script TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS builds (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
trigger_ref TEXT,
|
||||||
|
log TEXT DEFAULT '',
|
||||||
|
started_at INTEGER,
|
||||||
|
finished_at INTEGER
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import staticPlugin from "@fastify/static";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
|
import projectRoutes from "./routes/projects.js";
|
||||||
|
import buildRoutes from "./routes/builds.js";
|
||||||
|
import webhookRoutes from "./routes/webhook.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const fastify = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// Store raw body for webhook signature verification
|
||||||
|
fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (req, body, done) => {
|
||||||
|
req.rawBody = body;
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(body.toString()));
|
||||||
|
} catch (err) {
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
fastify.register(projectRoutes);
|
||||||
|
fastify.register(buildRoutes);
|
||||||
|
fastify.register(webhookRoutes);
|
||||||
|
|
||||||
|
// Serve frontend static files if built
|
||||||
|
const publicDir = join(__dirname, "../public");
|
||||||
|
if (existsSync(publicDir)) {
|
||||||
|
fastify.register(staticPlugin, { root: publicDir, prefix: "/" });
|
||||||
|
fastify.setNotFoundHandler((req, reply) => {
|
||||||
|
if (!req.url.startsWith("/api")) {
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
}
|
||||||
|
reply.code(404).send({ error: "Not found" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
fastify.listen({ port: PORT, host: "0.0.0.0" }, (err) => {
|
||||||
|
if (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import db from "../db/index.js";
|
||||||
|
|
||||||
|
export default async function buildRoutes(fastify) {
|
||||||
|
// List builds for a project
|
||||||
|
fastify.get("/api/projects/:projectId/builds", async (request, reply) => {
|
||||||
|
const { projectId } = request.params;
|
||||||
|
const project = db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId);
|
||||||
|
if (!project) return reply.code(404).send({ error: "Project not found" });
|
||||||
|
const builds = db.prepare("SELECT id, status, trigger_ref, started_at, finished_at FROM builds WHERE project_id = ? ORDER BY id DESC LIMIT 50").all(projectId);
|
||||||
|
return builds;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single build (without log)
|
||||||
|
fastify.get("/api/builds/:id", async (request, reply) => {
|
||||||
|
const build = db.prepare("SELECT id, project_id, status, trigger_ref, started_at, finished_at FROM builds WHERE id = ?").get(request.params.id);
|
||||||
|
if (!build) return reply.code(404).send({ error: "Not found" });
|
||||||
|
return build;
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSE: stream build log
|
||||||
|
fastify.get("/api/builds/:id/log", async (request, reply) => {
|
||||||
|
const buildId = request.params.id;
|
||||||
|
const build = db.prepare("SELECT * FROM builds WHERE id = ?").get(buildId);
|
||||||
|
if (!build) return reply.code(404).send({ error: "Not found" });
|
||||||
|
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = (data) => reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
|
||||||
|
// If already finished, send full log and close
|
||||||
|
if (build.status === "success" || build.status === "failed") {
|
||||||
|
send({ log: build.log, status: build.status, done: true });
|
||||||
|
reply.raw.end();
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll DB for new log content while running
|
||||||
|
let sentLength = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const current = db.prepare("SELECT log, status FROM builds WHERE id = ?").get(buildId);
|
||||||
|
if (!current) {
|
||||||
|
clearInterval(interval);
|
||||||
|
reply.raw.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newChunk = current.log.slice(sentLength);
|
||||||
|
if (newChunk) {
|
||||||
|
send({ chunk: newChunk });
|
||||||
|
sentLength = current.log.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.status === "success" || current.status === "failed") {
|
||||||
|
send({ status: current.status, done: true });
|
||||||
|
clearInterval(interval);
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
request.raw.on("close", () => clearInterval(interval));
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import db from "../db/index.js";
|
||||||
|
|
||||||
|
export default async function projectRoutes(fastify) {
|
||||||
|
// List all projects
|
||||||
|
fastify.get("/api/projects", async () => {
|
||||||
|
return db.prepare("SELECT * FROM projects ORDER BY created_at DESC").all();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single project
|
||||||
|
fastify.get("/api/projects/:id", async (request, reply) => {
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||||
|
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||||
|
return project;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
fastify.post("/api/projects", async (request, reply) => {
|
||||||
|
const { name, trigger_branch = "main", shell_script = "" } = request.body ?? {};
|
||||||
|
if (!name) return reply.code(400).send({ error: "name is required" });
|
||||||
|
const webhook_secret = randomBytes(20).toString("hex");
|
||||||
|
const project = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO projects (name, webhook_secret, trigger_branch, shell_script)
|
||||||
|
VALUES (?, ?, ?, ?) RETURNING *`,
|
||||||
|
)
|
||||||
|
.get(name, webhook_secret, trigger_branch, shell_script);
|
||||||
|
return reply.code(201).send(project);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update project
|
||||||
|
fastify.put("/api/projects/:id", async (request, reply) => {
|
||||||
|
const { name, trigger_branch, shell_script } = request.body ?? {};
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||||
|
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||||
|
const updated = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE projects SET
|
||||||
|
name = ?, trigger_branch = ?, shell_script = ?
|
||||||
|
WHERE id = ? RETURNING *`,
|
||||||
|
)
|
||||||
|
.get(name ?? project.name, trigger_branch ?? project.trigger_branch, shell_script ?? project.shell_script, request.params.id);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regenerate webhook secret
|
||||||
|
fastify.post("/api/projects/:id/regenerate-secret", async (request, reply) => {
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||||
|
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||||
|
const webhook_secret = randomBytes(20).toString("hex");
|
||||||
|
const updated = db.prepare(`UPDATE projects SET webhook_secret = ? WHERE id = ? RETURNING *`).get(webhook_secret, request.params.id);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete project
|
||||||
|
fastify.delete("/api/projects/:id", async (request, reply) => {
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||||
|
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||||
|
db.prepare("DELETE FROM projects WHERE id = ?").run(request.params.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manually trigger a build
|
||||||
|
fastify.post("/api/projects/:id/trigger", async (request, reply) => {
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||||
|
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||||
|
const { enqueue } = await import("../services/queue.js");
|
||||||
|
const { execute } = await import("../services/executor.js");
|
||||||
|
const build = db.prepare(`INSERT INTO builds (project_id, trigger_ref) VALUES (?, ?) RETURNING *`).get(request.params.id, "manual");
|
||||||
|
enqueue(request.params.id, () => execute(build.id, project.shell_script));
|
||||||
|
return reply.code(202).send({ buildId: build.id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { createHmac, timingSafeEqual } from "crypto";
|
||||||
|
import db from "../db/index.js";
|
||||||
|
import { enqueue } from "../services/queue.js";
|
||||||
|
import { execute } from "../services/executor.js";
|
||||||
|
|
||||||
|
export default async function webhookRoutes(fastify) {
|
||||||
|
fastify.post(
|
||||||
|
"/api/webhook/:projectId",
|
||||||
|
{
|
||||||
|
config: { rawBody: true },
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { projectId } = request.params;
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
||||||
|
|
||||||
|
if (!project) return reply.code(404).send({ error: "Project not found" });
|
||||||
|
|
||||||
|
// Verify Gitea HMAC-SHA256 signature
|
||||||
|
const sig = request.headers["x-gitea-signature"];
|
||||||
|
if (sig) {
|
||||||
|
const expected = createHmac("sha256", project.webhook_secret).update(request.rawBody).digest("hex");
|
||||||
|
try {
|
||||||
|
const sigBuf = Buffer.from(sig, "hex");
|
||||||
|
const expBuf = Buffer.from(expected, "hex");
|
||||||
|
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
|
||||||
|
return reply.code(401).send({ error: "Invalid signature" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: "Invalid signature" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.body;
|
||||||
|
const ref = payload?.ref ?? "";
|
||||||
|
const branch = ref.replace("refs/heads/", "");
|
||||||
|
|
||||||
|
if (project.trigger_branch && branch !== project.trigger_branch) {
|
||||||
|
return reply.send({ skipped: true, reason: `branch ${branch} not matched` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = db.prepare(`INSERT INTO builds (project_id, trigger_ref) VALUES (?, ?) RETURNING *`).get(projectId, ref);
|
||||||
|
|
||||||
|
enqueue(projectId, () => execute(build.id, project.shell_script));
|
||||||
|
|
||||||
|
return reply.code(202).send({ buildId: build.id });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import db from "../db/index.js";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
export async function execute(buildId, script) {
|
||||||
|
db.prepare(`UPDATE builds SET status = 'running', started_at = unixepoch() WHERE id = ?`).run(buildId);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("bash", ["-lc", script], {
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const appendLog = (chunk) => {
|
||||||
|
db.prepare(`UPDATE builds SET log = log || ? WHERE id = ?`).run(chunk, buildId);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on("data", (data) => appendLog(data.toString()));
|
||||||
|
child.stderr.on("data", (data) => appendLog(data.toString()));
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
appendLog("\n[TIMEOUT] Build exceeded 10 minutes and was killed.\n");
|
||||||
|
}, DEFAULT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const status = code === 0 ? "success" : "failed";
|
||||||
|
db.prepare(`UPDATE builds SET status = ?, finished_at = unixepoch() WHERE id = ?`).run(status, buildId);
|
||||||
|
resolve(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Per-project serial queue using promise chaining
|
||||||
|
const queues = new Map();
|
||||||
|
|
||||||
|
export function enqueue(projectId, task) {
|
||||||
|
const prev = queues.get(projectId) ?? Promise.resolve();
|
||||||
|
const next = prev.then(() => task()).catch(() => {});
|
||||||
|
queues.set(projectId, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
Loading…
Reference in New Issue