10.7 KB359 lines
Blame
1"use client";
2
3import { useEffect, useState, useRef } from "react";
4import { useRouter } from "next/navigation";
5import Link from "next/link";
6import { useAuth } from "@/lib/auth";
7import { instances as instancesApi } from "@/lib/api";
8
9type Phase = "form" | "script" | "waiting" | "done";
10
11export default function DeployPage() {
12 const { user, loading: authLoading } = useAuth();
13 const router = useRouter();
14
15 const [name, setName] = useState("grove");
16 const [domain, setDomain] = useState("");
17
18 const [phase, setPhase] = useState<Phase>("form");
19 const [error, setError] = useState("");
20 const [script, setScript] = useState("");
21 const [instanceId, setInstanceId] = useState<number | null>(null);
22 const [copied, setCopied] = useState(false);
23 const [instanceIp, setInstanceIp] = useState("");
24 const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
25
26 useEffect(() => {
27 document.title = "Deploy Instance";
28 }, []);
29
30 useEffect(() => {
31 if (!authLoading && !user) {
32 router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
33 }
34 }, [authLoading, user, router]);
35
36 useEffect(() => {
37 return () => {
38 if (pollRef.current) clearInterval(pollRef.current);
39 };
40 }, []);
41
42 if (authLoading || !user) return null;
43
44 async function handleGenerate(e: React.FormEvent) {
45 e.preventDefault();
46 setError("");
47
48 try {
49 // 1. Register instance with hub
50 const { instance } = await instancesApi.create({
51 name,
52 domain: domain || undefined,
53 });
54 setInstanceId(instance.id);
55
56 // 2. Fetch cloud-init template and replace placeholders
57 const res = await fetch("/cloud-init.sh");
58 let initScript = await res.text();
59
60 const token =
61 typeof window !== "undefined"
62 ? localStorage.getItem("grove_hub_token") ?? ""
63 : "";
64
65 initScript = initScript
66 .replace(/__INSTANCE_ID__/g, String(instance.id))
67 .replace(/__HUB_DOMAIN__/g, window.location.host)
68 .replace(/__INSTANCE_DOMAIN__/g, domain)
69 .replace(/__HUB_TOKEN__/g, token);
70
71 setScript(initScript);
72 setPhase("script");
73 } catch (err: unknown) {
74 setError(err instanceof Error ? err.message : "Failed to register instance");
75 }
76 }
77
78 function handleCopy() {
79 navigator.clipboard.writeText(script);
80 setCopied(true);
81 setTimeout(() => setCopied(false), 2000);
82 }
83
84 function handleStartPolling() {
85 setPhase("waiting");
86 pollRef.current = setInterval(async () => {
87 try {
88 const { instances } = await instancesApi.list();
89 const inst = instances.find((i) => i.id === instanceId);
90 if (inst?.status === "active") {
91 setInstanceIp(inst.ip ?? "");
92 setPhase("done");
93 if (pollRef.current) clearInterval(pollRef.current);
94 }
95 } catch {
96 // keep polling
97 }
98 }, 5000);
99 }
100
101 const instanceUrl = domain
102 ? `https://${domain}`
103 : instanceIp
104 ? `http://${instanceIp}`
105 : "";
106
107 return (
108 <div className="max-w-2xl mx-auto px-4 py-10">
109 <div className="flex items-center justify-between mb-6">
110 <h1 className="text-xl">Deploy Instance</h1>
111 <Link
112 href="/dashboard"
113 className="text-sm hover:underline"
114 style={{ color: "var(--text-muted)" }}
115 >
116 Back
117 </Link>
118 </div>
119
120 {phase === "form" && (
121 <>
122 <p className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>
123 Generate a setup script to deploy Grove on any VPS (DigitalOcean,
124 Hetzner, Linode, Vultr, etc).
125 </p>
126
127 <form onSubmit={handleGenerate} className="space-y-4">
128 <div>
129 <label
130 className="block text-sm mb-1"
131 style={{ color: "var(--text-muted)" }}
132 >
133 Instance Name
134 </label>
135 <input
136 type="text"
137 value={name}
138 onChange={(e) => setName(e.target.value)}
139 className="w-full px-2 py-1.5 text-sm focus:outline-none"
140 style={{
141 backgroundColor: "var(--bg-input)",
142 border: "1px solid var(--border)",
143 color: "var(--text-primary)",
144 }}
145 placeholder="grove"
146 required
147 />
148 </div>
149
150 <div>
151 <label
152 className="block text-sm mb-1"
153 style={{ color: "var(--text-muted)" }}
154 >
155 Domain{" "}
156 <span style={{ color: "var(--text-faint)" }}>(optional)</span>
157 </label>
158 <input
159 type="text"
160 value={domain}
161 onChange={(e) => setDomain(e.target.value)}
162 className="w-full px-2 py-1.5 text-sm focus:outline-none"
163 style={{
164 backgroundColor: "var(--bg-input)",
165 border: "1px solid var(--border)",
166 color: "var(--text-primary)",
167 }}
168 placeholder="grove.example.com"
169 />
170 <p className="text-xs mt-1" style={{ color: "var(--text-faint)" }}>
171 Leave blank to use the server IP. If set, point a DNS A record
172 to the server IP after deploy.
173 </p>
174 </div>
175
176 {error && (
177 <p className="text-sm" style={{ color: "var(--error-text)" }}>
178 {error}
179 </p>
180 )}
181
182 <button
183 type="submit"
184 className="text-sm px-3 py-1.5"
185 style={{
186 backgroundColor: "var(--accent)",
187 color: "var(--accent-text)",
188 }}
189 >
190 Generate Script
191 </button>
192 </form>
193 </>
194 )}
195
196 {phase === "script" && (
197 <>
198 <div className="mb-4">
199 <h2 className="text-sm mb-2" style={{ color: "var(--text-secondary)" }}>
200 Setup Instructions
201 </h2>
202 <ol
203 className="text-sm space-y-1.5 mb-4"
204 style={{ color: "var(--text-muted)" }}
205 >
206 <li>1. Create a VPS with any provider (min 2 vCPU, 4 GB RAM, Ubuntu 22.04+)</li>
207 <li>2. SSH into the server and paste the script below, or use it as cloud-init / user-data</li>
208 {domain && (
209 <li>3. Point a DNS A record for <strong>{domain}</strong> to the server IP</li>
210 )}
211 </ol>
212 </div>
213
214 <div className="relative mb-4">
215 <button
216 onClick={handleCopy}
217 className="absolute top-2 right-2 text-xs px-2 py-1"
218 style={{
219 backgroundColor: "var(--bg-inset)",
220 border: "1px solid var(--border)",
221 color: "var(--text-secondary)",
222 cursor: "pointer",
223 }}
224 >
225 {copied ? "Copied!" : "Copy"}
226 </button>
227 <pre
228 className="text-xs p-3 overflow-x-auto"
229 style={{
230 backgroundColor: "var(--bg-inset)",
231 border: "1px solid var(--border)",
232 color: "var(--text-secondary)",
233 maxHeight: "400px",
234 overflowY: "auto",
235 }}
236 >
237 {script}
238 </pre>
239 </div>
240
241 <button
242 onClick={handleStartPolling}
243 className="text-sm px-3 py-1.5"
244 style={{
245 backgroundColor: "var(--accent)",
246 color: "var(--accent-text)",
247 }}
248 >
249 I&apos;ve started the script — wait for instance
250 </button>
251 </>
252 )}
253
254 {phase === "waiting" && (
255 <div>
256 <p className="text-sm mb-2" style={{ color: "var(--text-muted)" }}>
257 Waiting for instance to come online...
258 </p>
259 <p className="text-xs" style={{ color: "var(--text-faint)" }}>
260 The script will notify this hub when setup completes. This may take
261 a few minutes depending on the server.
262 </p>
263 </div>
264 )}
265
266 {phase === "done" && (
267 <div>
268 <h2 className="text-lg mb-4">Instance deployed</h2>
269 <hr className="my-4" style={{ borderColor: "var(--border)" }} />
270
271 <label
272 className="block text-xs mb-1"
273 style={{ color: "var(--text-muted)" }}
274 >
275 Instance URL
276 </label>
277 <code
278 className="block text-sm mb-4"
279 style={{
280 color: "var(--text-secondary)",
281 userSelect: "all" as const,
282 }}
283 >
284 {instanceUrl}
285 </code>
286
287 {instanceIp && (
288 <>
289 <label
290 className="block text-xs mb-1"
291 style={{ color: "var(--text-muted)" }}
292 >
293 IP Address
294 </label>
295 <code
296 className="block text-sm mb-4"
297 style={{
298 color: "var(--text-secondary)",
299 userSelect: "all" as const,
300 }}
301 >
302 {instanceIp}
303 </code>
304 </>
305 )}
306
307 <hr className="my-4" style={{ borderColor: "var(--border)" }} />
308
309 <h3
310 className="text-sm mb-2"
311 style={{ color: "var(--text-secondary)" }}
312 >
313 Next steps
314 </h3>
315 <ol
316 className="text-sm space-y-2 mb-6"
317 style={{ color: "var(--text-muted)" }}
318 >
319 <li>
320 1.{" "}
321 <Link
322 href="/dashboard"
323 style={{ color: "var(--accent)" }}
324 className="hover:underline"
325 >
326 Create a repository
327 </Link>{" "}
328 from your dashboard
329 </li>
330 <li>
331 2. Clone and push code:
332 <code
333 className="block mt-1 text-xs font-mono"
334 style={{
335 color: "var(--text-secondary)",
336 userSelect: "all" as const,
337 }}
338 >
339 sl clone {instanceUrl ? `${instanceUrl}/<repo-name>` : "<instance-url>/<repo-name>"}
340 </code>
341 </li>
342 </ol>
343
344 <Link
345 href="/dashboard"
346 className="text-sm px-3 py-1.5 inline-block"
347 style={{
348 backgroundColor: "var(--accent)",
349 color: "var(--accent-text)",
350 }}
351 >
352 Go to Dashboard
353 </Link>
354 </div>
355 )}
356 </div>
357 );
358}
359