web/app/deploy/page.tsxblame
View source
4a006da1"use client";
4a006da2
966d71f3import { useEffect, useState, useRef } from "react";
4a006da4import { useRouter } from "next/navigation";
4a006da5import Link from "next/link";
4a006da6import { useAuth } from "@/lib/auth";
4a006da7import { instances as instancesApi } from "@/lib/api";
4a006da8
966d71f9type Phase = "form" | "script" | "waiting" | "done";
4a006da10
4a006da11export default function DeployPage() {
4a006da12 const { user, loading: authLoading } = useAuth();
4a006da13 const router = useRouter();
4a006da14
966d71f15 const [name, setName] = useState("grove");
4a006da16 const [domain, setDomain] = useState("");
4a006da17
4a006da18 const [phase, setPhase] = useState<Phase>("form");
4a006da19 const [error, setError] = useState("");
966d71f20 const [script, setScript] = useState("");
966d71f21 const [instanceId, setInstanceId] = useState<number | null>(null);
966d71f22 const [copied, setCopied] = useState(false);
966d71f23 const [instanceIp, setInstanceIp] = useState("");
966d71f24 const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
4a006da25
1da987426 useEffect(() => {
1da987427 document.title = "Deploy Instance";
1da987428 }, []);
1da987429
4a006da30 useEffect(() => {
4a006da31 if (!authLoading && !user) {
6dd74de32 router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
4a006da33 }
4a006da34 }, [authLoading, user, router]);
4a006da35
966d71f36 useEffect(() => {
966d71f37 return () => {
966d71f38 if (pollRef.current) clearInterval(pollRef.current);
966d71f39 };
966d71f40 }, []);
4a006da41
966d71f42 if (authLoading || !user) return null;
4a006da43
966d71f44 async function handleGenerate(e: React.FormEvent) {
4a006da45 e.preventDefault();
4a006da46 setError("");
4a006da47
4a006da48 try {
966d71f49 // 1. Register instance with hub
80fafdf50 const { instance } = await instancesApi.create({
966d71f51 name,
80fafdf52 domain: domain || undefined,
80fafdf53 });
966d71f54 setInstanceId(instance.id);
4a006da55
966d71f56 // 2. Fetch cloud-init template and replace placeholders
966d71f57 const res = await fetch("/cloud-init.sh");
966d71f58 let initScript = await res.text();
4a006da59
966d71f60 const token =
966d71f61 typeof window !== "undefined"
966d71f62 ? localStorage.getItem("grove_hub_token") ?? ""
966d71f63 : "";
4a006da64
966d71f65 initScript = initScript
966d71f66 .replace(/__INSTANCE_ID__/g, String(instance.id))
966d71f67 .replace(/__HUB_DOMAIN__/g, window.location.host)
966d71f68 .replace(/__INSTANCE_DOMAIN__/g, domain)
966d71f69 .replace(/__HUB_TOKEN__/g, token);
4a006da70
966d71f71 setScript(initScript);
966d71f72 setPhase("script");
966d71f73 } catch (err: unknown) {
966d71f74 setError(err instanceof Error ? err.message : "Failed to register instance");
966d71f75 }
966d71f76 }
4a006da77
966d71f78 function handleCopy() {
966d71f79 navigator.clipboard.writeText(script);
966d71f80 setCopied(true);
966d71f81 setTimeout(() => setCopied(false), 2000);
966d71f82 }
966d71f83
966d71f84 function handleStartPolling() {
966d71f85 setPhase("waiting");
966d71f86 pollRef.current = setInterval(async () => {
966d71f87 try {
966d71f88 const { instances } = await instancesApi.list();
966d71f89 const inst = instances.find((i) => i.id === instanceId);
966d71f90 if (inst?.status === "active") {
966d71f91 setInstanceIp(inst.ip ?? "");
966d71f92 setPhase("done");
966d71f93 if (pollRef.current) clearInterval(pollRef.current);
4a006da94 }
966d71f95 } catch {
966d71f96 // keep polling
4a006da97 }
966d71f98 }, 5000);
4a006da99 }
4a006da100
966d71f101 const instanceUrl = domain
966d71f102 ? `https://${domain}`
966d71f103 : instanceIp
966d71f104 ? `http://${instanceIp}`
966d71f105 : "";
966d71f106
4a006da107 return (
966d71f108 <div className="max-w-2xl mx-auto px-4 py-10">
4a006da109 <div className="flex items-center justify-between mb-6">
cf89d3c110 <h1 className="text-xl">Deploy Instance</h1>
4a006da111 <Link
4a006da112 href="/dashboard"
4a006da113 className="text-sm hover:underline"
4a006da114 style={{ color: "var(--text-muted)" }}
4a006da115 >
4a006da116 Back
4a006da117 </Link>
4a006da118 </div>
4a006da119
4a006da120 {phase === "form" && (
4a006da121 <>
4a006da122 <p className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>
966d71f123 Generate a setup script to deploy Grove on any VPS (DigitalOcean,
966d71f124 Hetzner, Linode, Vultr, etc).
4a006da125 </p>
4a006da126
966d71f127 <form onSubmit={handleGenerate} className="space-y-4">
4a006da128 <div>
4a006da129 <label
4a006da130 className="block text-sm mb-1"
4a006da131 style={{ color: "var(--text-muted)" }}
4a006da132 >
966d71f133 Instance Name
4a006da134 </label>
4a006da135 <input
966d71f136 type="text"
966d71f137 value={name}
966d71f138 onChange={(e) => setName(e.target.value)}
4a006da139 className="w-full px-2 py-1.5 text-sm focus:outline-none"
4a006da140 style={{
4a006da141 backgroundColor: "var(--bg-input)",
4a006da142 border: "1px solid var(--border)",
4a006da143 color: "var(--text-primary)",
4a006da144 }}
966d71f145 placeholder="grove"
966d71f146 required
4a006da147 />
4a006da148 </div>
4a006da149
4a006da150 <div>
4a006da151 <label
4a006da152 className="block text-sm mb-1"
4a006da153 style={{ color: "var(--text-muted)" }}
4a006da154 >
4a006da155 Domain{" "}
4a006da156 <span style={{ color: "var(--text-faint)" }}>(optional)</span>
4a006da157 </label>
4a006da158 <input
4a006da159 type="text"
4a006da160 value={domain}
4a006da161 onChange={(e) => setDomain(e.target.value)}
4a006da162 className="w-full px-2 py-1.5 text-sm focus:outline-none"
4a006da163 style={{
4a006da164 backgroundColor: "var(--bg-input)",
4a006da165 border: "1px solid var(--border)",
4a006da166 color: "var(--text-primary)",
4a006da167 }}
4a006da168 placeholder="grove.example.com"
4a006da169 />
4a006da170 <p className="text-xs mt-1" style={{ color: "var(--text-faint)" }}>
966d71f171 Leave blank to use the server IP. If set, point a DNS A record
966d71f172 to the server IP after deploy.
4a006da173 </p>
4a006da174 </div>
4a006da175
4a006da176 {error && (
4a006da177 <p className="text-sm" style={{ color: "var(--error-text)" }}>
4a006da178 {error}
4a006da179 </p>
4a006da180 )}
4a006da181
4a006da182 <button
4a006da183 type="submit"
4a006da184 className="text-sm px-3 py-1.5"
4a006da185 style={{
4a006da186 backgroundColor: "var(--accent)",
4a006da187 color: "var(--accent-text)",
4a006da188 }}
4a006da189 >
966d71f190 Generate Script
4a006da191 </button>
4a006da192 </form>
4a006da193 </>
4a006da194 )}
4a006da195
966d71f196 {phase === "script" && (
966d71f197 <>
966d71f198 <div className="mb-4">
966d71f199 <h2 className="text-sm mb-2" style={{ color: "var(--text-secondary)" }}>
966d71f200 Setup Instructions
966d71f201 </h2>
966d71f202 <ol
966d71f203 className="text-sm space-y-1.5 mb-4"
966d71f204 style={{ color: "var(--text-muted)" }}
966d71f205 >
966d71f206 <li>1. Create a VPS with any provider (min 2 vCPU, 4 GB RAM, Ubuntu 22.04+)</li>
966d71f207 <li>2. SSH into the server and paste the script below, or use it as cloud-init / user-data</li>
966d71f208 {domain && (
966d71f209 <li>3. Point a DNS A record for <strong>{domain}</strong> to the server IP</li>
966d71f210 )}
966d71f211 </ol>
966d71f212 </div>
966d71f213
966d71f214 <div className="relative mb-4">
966d71f215 <button
966d71f216 onClick={handleCopy}
966d71f217 className="absolute top-2 right-2 text-xs px-2 py-1"
966d71f218 style={{
966d71f219 backgroundColor: "var(--bg-inset)",
966d71f220 border: "1px solid var(--border)",
966d71f221 color: "var(--text-secondary)",
966d71f222 cursor: "pointer",
966d71f223 }}
966d71f224 >
966d71f225 {copied ? "Copied!" : "Copy"}
966d71f226 </button>
966d71f227 <pre
966d71f228 className="text-xs p-3 overflow-x-auto"
966d71f229 style={{
966d71f230 backgroundColor: "var(--bg-inset)",
966d71f231 border: "1px solid var(--border)",
966d71f232 color: "var(--text-secondary)",
966d71f233 maxHeight: "400px",
966d71f234 overflowY: "auto",
966d71f235 }}
966d71f236 >
966d71f237 {script}
966d71f238 </pre>
966d71f239 </div>
966d71f240
966d71f241 <button
966d71f242 onClick={handleStartPolling}
966d71f243 className="text-sm px-3 py-1.5"
966d71f244 style={{
966d71f245 backgroundColor: "var(--accent)",
966d71f246 color: "var(--accent-text)",
966d71f247 }}
966d71f248 >
966d71f249 I&apos;ve started the script — wait for instance
966d71f250 </button>
966d71f251 </>
966d71f252 )}
966d71f253
966d71f254 {phase === "waiting" && (
966d71f255 <div>
966d71f256 <p className="text-sm mb-2" style={{ color: "var(--text-muted)" }}>
966d71f257 Waiting for instance to come online...
966d71f258 </p>
966d71f259 <p className="text-xs" style={{ color: "var(--text-faint)" }}>
966d71f260 The script will notify this hub when setup completes. This may take
966d71f261 a few minutes depending on the server.
966d71f262 </p>
966d71f263 </div>
4a006da264 )}
4a006da265
4a006da266 {phase === "done" && (
4a006da267 <div>
80fafdf268 <h2 className="text-lg mb-4">Instance deployed</h2>
4a006da269 <hr className="my-4" style={{ borderColor: "var(--border)" }} />
4a006da270
4a006da271 <label
4a006da272 className="block text-xs mb-1"
4a006da273 style={{ color: "var(--text-muted)" }}
4a006da274 >
80fafdf275 Instance URL
4a006da276 </label>
4a006da277 <code
4a006da278 className="block text-sm mb-4"
966d71f279 style={{
966d71f280 color: "var(--text-secondary)",
966d71f281 userSelect: "all" as const,
966d71f282 }}
4a006da283 >
966d71f284 {instanceUrl}
4a006da285 </code>
4a006da286
966d71f287 {instanceIp && (
966d71f288 <>
966d71f289 <label
966d71f290 className="block text-xs mb-1"
966d71f291 style={{ color: "var(--text-muted)" }}
966d71f292 >
966d71f293 IP Address
966d71f294 </label>
966d71f295 <code
966d71f296 className="block text-sm mb-4"
966d71f297 style={{
966d71f298 color: "var(--text-secondary)",
966d71f299 userSelect: "all" as const,
966d71f300 }}
966d71f301 >
966d71f302 {instanceIp}
966d71f303 </code>
966d71f304 </>
966d71f305 )}
4a006da306
4a006da307 <hr className="my-4" style={{ borderColor: "var(--border)" }} />
80fafdf308
966d71f309 <h3
966d71f310 className="text-sm mb-2"
966d71f311 style={{ color: "var(--text-secondary)" }}
966d71f312 >
80fafdf313 Next steps
80fafdf314 </h3>
80fafdf315 <ol
80fafdf316 className="text-sm space-y-2 mb-6"
80fafdf317 style={{ color: "var(--text-muted)" }}
80fafdf318 >
80fafdf319 <li>
80fafdf320 1.{" "}
80fafdf321 <Link
80fafdf322 href="/dashboard"
80fafdf323 style={{ color: "var(--accent)" }}
80fafdf324 className="hover:underline"
80fafdf325 >
80fafdf326 Create a repository
80fafdf327 </Link>{" "}
80fafdf328 from your dashboard
80fafdf329 </li>
80fafdf330 <li>
966d71f331 2. Clone and push code:
80fafdf332 <code
80fafdf333 className="block mt-1 text-xs font-mono"
966d71f334 style={{
966d71f335 color: "var(--text-secondary)",
966d71f336 userSelect: "all" as const,
966d71f337 }}
80fafdf338 >
966d71f339 sl clone {instanceUrl ? `${instanceUrl}/<repo-name>` : "<instance-url>/<repo-name>"}
80fafdf340 </code>
80fafdf341 </li>
80fafdf342 </ol>
80fafdf343
4a006da344 <Link
4a006da345 href="/dashboard"
4a006da346 className="text-sm px-3 py-1.5 inline-block"
4a006da347 style={{
80fafdf348 backgroundColor: "var(--accent)",
80fafdf349 color: "var(--accent-text)",
4a006da350 }}
4a006da351 >
80fafdf352 Go to Dashboard
4a006da353 </Link>
4a006da354 </div>
4a006da355 )}
4a006da356 </div>
4a006da357 );
4a006da358}