Init: First Commit
第一条提交
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user