larkup 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +2 -0
- package/app/actions/vercel.ts +417 -0
- package/app/api/chat/route.ts +192 -0
- package/app/api/chat/status/route.ts +55 -0
- package/app/api/chat/suggestions/route.ts +115 -0
- package/app/api/config/route.ts +73 -0
- package/app/api/config/test/route.ts +67 -0
- package/app/api/config/test-embedding/route.ts +40 -0
- package/app/api/config/test-llm/route.ts +47 -0
- package/app/api/config/test-provider/route.ts +36 -0
- package/app/api/demo/route.ts +176 -0
- package/app/api/deploy/ssh/route.ts +340 -0
- package/app/api/documents/route.ts +90 -0
- package/app/api/firecrawl/local/route.ts +46 -0
- package/app/api/index/route.ts +113 -0
- package/app/api/jobs/[id]/route.ts +44 -0
- package/app/api/jobs/route.ts +116 -0
- package/app/api/parse-file/route.ts +39 -0
- package/app/api/search/google/route.ts +162 -0
- package/app/api/search/route.ts +29 -0
- package/app/api/server/download/route.ts +112 -0
- package/app/api/server/generate/route.ts +46 -0
- package/app/api/server/local/route.ts +42 -0
- package/app/api/servers/route.ts +137 -0
- package/app/api/vector-stores/install/route.ts +65 -0
- package/app/api/vector-stores/status/route.ts +26 -0
- package/app/chat/page.tsx +15 -0
- package/app/configure/page.tsx +53 -0
- package/app/data/page.tsx +15 -0
- package/app/demo/page.tsx +15 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +190 -0
- package/app/index-data/page.tsx +15 -0
- package/app/layout.tsx +51 -0
- package/app/page.tsx +34 -0
- package/app/server/page.tsx +22 -0
- package/app/simple/chat/page.tsx +17 -0
- package/app/simple/docs/page.tsx +21 -0
- package/app/simple/layout.tsx +9 -0
- package/app/simple/settings/page.tsx +627 -0
- package/app/template.tsx +16 -0
- package/app/themes.css +733 -0
- package/components/alerts/generic-alert.tsx +74 -0
- package/components/app-sidebar.tsx +187 -0
- package/components/app-topnav.tsx +195 -0
- package/components/chat/chat-settings-modal.tsx +209 -0
- package/components/chat/chat-workspace.tsx +348 -0
- package/components/chat/knowledge-base-result.tsx +97 -0
- package/components/chat/message-item.tsx +103 -0
- package/components/client-layout-wrapper.tsx +182 -0
- package/components/configure/configure-form.tsx +1683 -0
- package/components/configure/store-fields.tsx +152 -0
- package/components/data/corpus-panel.tsx +499 -0
- package/components/data/data-workspace.tsx +272 -0
- package/components/data/firecrawl-notice.tsx +236 -0
- package/components/data/jobs-panel.tsx +485 -0
- package/components/data/paste-panel.tsx +79 -0
- package/components/data/scrape-panel.tsx +964 -0
- package/components/data/upload-panel.tsx +714 -0
- package/components/demo/demo-workspace.tsx +419 -0
- package/components/global-settings.tsx +286 -0
- package/components/index/index-workspace.tsx +482 -0
- package/components/onboarding/simple-setup.tsx +702 -0
- package/components/onboarding/tech-setup.tsx +121 -0
- package/components/onboarding/welcome-screen.tsx +183 -0
- package/components/page-header.tsx +44 -0
- package/components/server/code-viewer.tsx +87 -0
- package/components/server/deploy-button.tsx +1390 -0
- package/components/server/server-workspace.tsx +392 -0
- package/components/simple/sdk-connect-dialog.tsx +201 -0
- package/components/simple/simple-index-button.tsx +83 -0
- package/components/simple/simple-sidebar.tsx +161 -0
- package/components/theme-customizer-provider.tsx +171 -0
- package/components/theme-switcher.tsx +445 -0
- package/components/ui/alert-dialog.tsx +187 -0
- package/components/ui/alert.tsx +76 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/checkbox.tsx +28 -0
- package/components/ui/collapsible.tsx +21 -0
- package/components/ui/dialog.tsx +159 -0
- package/components/ui/dropdown-menu.tsx +281 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +20 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/progress.tsx +83 -0
- package/components/ui/provider-icon.tsx +94 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +248 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/sonner.tsx +63 -0
- package/components/ui/switch.tsx +32 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +84 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/workspace/delete-server-dialog.tsx +71 -0
- package/components/workspace/server-form-dialog.tsx +103 -0
- package/components/workspace/server-switcher.tsx +163 -0
- package/components/workspace/workspace-provider.tsx +177 -0
- package/components/workspace/workspace-top-bar.tsx +23 -0
- package/components.json +21 -0
- package/lib/error-formatter.ts +15 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +18 -0
- package/package.json +65 -0
- package/postcss.config.mjs +8 -0
- package/public/docker.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/github.svg +3 -0
- package/public/hetzner.svg +1 -0
- package/public/icons/aws.svg +38 -0
- package/public/icons/azure.svg +1 -0
- package/public/icons/chroma.png +0 -0
- package/public/icons/cohere.svg +30 -0
- package/public/icons/digital-ocean.webp +0 -0
- package/public/icons/firecrawl2.png +0 -0
- package/public/icons/gcp.svg +1 -0
- package/public/icons/gemini.svg +1 -0
- package/public/icons/jina.svg +1 -0
- package/public/icons/lancedb2.png +0 -0
- package/public/icons/mistral.svg +469 -0
- package/public/icons/nomic.png +0 -0
- package/public/icons/openai.svg +1 -0
- package/public/icons/pgvector2.png +0 -0
- package/public/icons/pinecone.png +0 -0
- package/public/icons/qdrant.svg +35 -0
- package/public/icons/supabase.png +0 -0
- package/public/icons/vercel copy.svg +1 -0
- package/public/icons/vercel.svg +1 -0
- package/public/icons/voyage-light.png +0 -0
- package/public/icons/weaviate.webp +0 -0
- package/public/logo-light.png +0 -0
- package/public/logo.png +0 -0
- package/public/logo9.png +0 -0
- package/public/vercel.svg +1 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
Rocket,
|
|
7
|
+
Loader2,
|
|
8
|
+
ChevronDown,
|
|
9
|
+
Eye,
|
|
10
|
+
EyeOff,
|
|
11
|
+
CheckCircle2,
|
|
12
|
+
Dices,
|
|
13
|
+
Copy,
|
|
14
|
+
Settings2,
|
|
15
|
+
KeyRound,
|
|
16
|
+
ShieldAlert,
|
|
17
|
+
} from "lucide-react";
|
|
18
|
+
import { toast } from "sonner";
|
|
19
|
+
import { deployToVercel, getServerEnvRequirements } from "@/app/actions/vercel";
|
|
20
|
+
// import removed
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuContent,
|
|
24
|
+
DropdownMenuItem,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
DropdownMenuSub,
|
|
27
|
+
DropdownMenuSubTrigger,
|
|
28
|
+
DropdownMenuSubContent,
|
|
29
|
+
} from "@/components/ui/dropdown-menu";
|
|
30
|
+
import {
|
|
31
|
+
Dialog,
|
|
32
|
+
DialogContent,
|
|
33
|
+
DialogDescription,
|
|
34
|
+
DialogHeader,
|
|
35
|
+
DialogTitle,
|
|
36
|
+
DialogFooter,
|
|
37
|
+
} from "@/components/ui/dialog";
|
|
38
|
+
import {
|
|
39
|
+
Sheet,
|
|
40
|
+
SheetContent,
|
|
41
|
+
SheetDescription,
|
|
42
|
+
SheetHeader,
|
|
43
|
+
SheetTitle,
|
|
44
|
+
SheetFooter,
|
|
45
|
+
} from "@/components/ui/sheet";
|
|
46
|
+
import { Input } from "@/components/ui/input";
|
|
47
|
+
import { Label } from "@/components/ui/label";
|
|
48
|
+
import { Badge } from "@/components/ui/badge";
|
|
49
|
+
import {
|
|
50
|
+
Tooltip,
|
|
51
|
+
TooltipContent,
|
|
52
|
+
TooltipProvider,
|
|
53
|
+
TooltipTrigger,
|
|
54
|
+
} from "../ui/tooltip";
|
|
55
|
+
|
|
56
|
+
// ─── Storage helpers ──────────────────────────────────────────────────────────
|
|
57
|
+
// Token is stored globally so it loads on every server automatically.
|
|
58
|
+
// Project name and deployed URL are stored per-server (keyed by serverId).
|
|
59
|
+
|
|
60
|
+
const GLOBAL_TOKEN_KEY = "vercel_token";
|
|
61
|
+
|
|
62
|
+
function projectKey(serverId: string) {
|
|
63
|
+
return `vercel_project_${serverId}`;
|
|
64
|
+
}
|
|
65
|
+
function deployedUrlKey(serverId: string) {
|
|
66
|
+
return `vercel_deployed_url_${serverId}`;
|
|
67
|
+
}
|
|
68
|
+
function deployedProviderKey(serverId: string) {
|
|
69
|
+
return `vercel_deployed_provider_${serverId}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Vercel triangle SVG icon ─────────────────────────────────────────────────
|
|
73
|
+
function VercelIcon({ className }: { className?: string }) {
|
|
74
|
+
return (
|
|
75
|
+
<svg
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
fill="currentColor"
|
|
78
|
+
className={className}
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
>
|
|
81
|
+
<path d="M12 2L2 19.778h20L12 2z" />
|
|
82
|
+
</svg>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
interface DeployButtonProps {
|
|
89
|
+
/** Unique identifier for this server instance (used to scope the project name). */
|
|
90
|
+
serverId?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function DeployButton({ serverId = "default" }: DeployButtonProps) {
|
|
94
|
+
const [vercelModalOpen, setVercelModalOpen] = useState(false);
|
|
95
|
+
const [envModalOpen, setEnvModalOpen] = useState(false);
|
|
96
|
+
const [vercelToken, setVercelToken] = useState("");
|
|
97
|
+
const [vercelProject, setVercelProject] = useState("");
|
|
98
|
+
const [apiKey, setApiKey] = useState("");
|
|
99
|
+
const [isDeploying, setIsDeploying] = useState(false);
|
|
100
|
+
const [showToken, setShowToken] = useState(false);
|
|
101
|
+
const [showApiKey, setShowApiKey] = useState(false);
|
|
102
|
+
const [showEnvVars, setShowEnvVars] = useState<Record<string, boolean>>({});
|
|
103
|
+
|
|
104
|
+
// ── SSH Deployment State
|
|
105
|
+
const [sshModalOpen, setSshModalOpen] = useState(false);
|
|
106
|
+
const [sshHost, setSshHost] = useState("");
|
|
107
|
+
const [sshUsername, setSshUsername] = useState("root");
|
|
108
|
+
const [sshAuthType, setSshAuthType] = useState<"key" | "password">(
|
|
109
|
+
"password",
|
|
110
|
+
);
|
|
111
|
+
const [sshKeyOrPassword, setSshKeyOrPassword] = useState("");
|
|
112
|
+
const [showSshPassword, setShowSshPassword] = useState(false);
|
|
113
|
+
|
|
114
|
+
// ── Password Change Dialog State (Hetzner first-login)
|
|
115
|
+
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
|
|
116
|
+
const [newSshPassword, setNewSshPassword] = useState("");
|
|
117
|
+
const [confirmNewSshPassword, setConfirmNewSshPassword] = useState("");
|
|
118
|
+
const [showNewSshPassword, setShowNewSshPassword] = useState(false);
|
|
119
|
+
const [showConfirmSshPassword, setShowConfirmSshPassword] = useState(false);
|
|
120
|
+
// Store deploy params so we can retry after password change
|
|
121
|
+
const pendingDeployRef = useRef<{
|
|
122
|
+
host: string;
|
|
123
|
+
username: string;
|
|
124
|
+
auth: { type: string; value: string };
|
|
125
|
+
envVars: Record<string, string>;
|
|
126
|
+
} | null>(null);
|
|
127
|
+
|
|
128
|
+
// Dynamic Environment Variables
|
|
129
|
+
const [requiredEnv, setRequiredEnv] = useState<
|
|
130
|
+
{ key: string; help: string; required: boolean; defaultValue?: string }[]
|
|
131
|
+
>([]);
|
|
132
|
+
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
|
133
|
+
const [sshLogs, setSshLogs] = useState<string[]>([]);
|
|
134
|
+
|
|
135
|
+
// ── Was the token already saved globally?
|
|
136
|
+
const [tokenWasSaved, setTokenWasSaved] = useState(false);
|
|
137
|
+
|
|
138
|
+
// ── Persisted deployment info (shown in UI after successful deploy)
|
|
139
|
+
const [deployedUrl, setDeployedUrl] = useState<string | null>(null);
|
|
140
|
+
const [deployedProvider, setDeployedProvider] = useState<string | null>(null);
|
|
141
|
+
|
|
142
|
+
// ── Env summary counts
|
|
143
|
+
const envSummary = useMemo(() => {
|
|
144
|
+
const total = requiredEnv.length + 1; // +1 for SERVER_API_KEY
|
|
145
|
+
const filled =
|
|
146
|
+
requiredEnv.filter((e) => !!envValues[e.key]).length + (apiKey ? 1 : 0);
|
|
147
|
+
const requiredMissing = requiredEnv.filter(
|
|
148
|
+
(e) => e.required && !envValues[e.key],
|
|
149
|
+
).length;
|
|
150
|
+
return { total, filled, requiredMissing };
|
|
151
|
+
}, [requiredEnv, envValues, apiKey]);
|
|
152
|
+
|
|
153
|
+
// Load on mount: token is global, project/url are per-server
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const savedToken = localStorage.getItem(GLOBAL_TOKEN_KEY);
|
|
156
|
+
const savedProject = localStorage.getItem(projectKey(serverId));
|
|
157
|
+
const savedUrl = localStorage.getItem(deployedUrlKey(serverId));
|
|
158
|
+
const savedProvider = localStorage.getItem(deployedProviderKey(serverId));
|
|
159
|
+
const savedApiKey = localStorage.getItem("rag_server_api_key");
|
|
160
|
+
|
|
161
|
+
if (savedApiKey) {
|
|
162
|
+
setApiKey(savedApiKey);
|
|
163
|
+
}
|
|
164
|
+
if (savedToken) {
|
|
165
|
+
setVercelToken(savedToken);
|
|
166
|
+
setTokenWasSaved(true);
|
|
167
|
+
}
|
|
168
|
+
if (savedProject) {
|
|
169
|
+
setVercelProject(savedProject);
|
|
170
|
+
}
|
|
171
|
+
if (savedUrl) {
|
|
172
|
+
setDeployedUrl(savedUrl);
|
|
173
|
+
}
|
|
174
|
+
if (savedProvider) {
|
|
175
|
+
setDeployedProvider(savedProvider);
|
|
176
|
+
}
|
|
177
|
+
}, [serverId]);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if ((vercelModalOpen || envModalOpen || sshModalOpen) && serverId) {
|
|
181
|
+
getServerEnvRequirements(serverId).then((reqs) => {
|
|
182
|
+
setRequiredEnv(reqs);
|
|
183
|
+
// Only pre-fill keys that are present in the CURRENT config's .env.example.
|
|
184
|
+
// This prevents stale keys from a previously-selected store (e.g. LanceDB vars
|
|
185
|
+
// bleeding into a Pinecone configuration) from appearing in the sheet.
|
|
186
|
+
const savedEnvStr = localStorage.getItem("rag_server_env_vars");
|
|
187
|
+
const savedEnv = savedEnvStr ? JSON.parse(savedEnvStr) : {};
|
|
188
|
+
|
|
189
|
+
const initialVals: Record<string, string> = {};
|
|
190
|
+
for (const req of reqs) {
|
|
191
|
+
// Only use the saved value if the key is actually required by the current config.
|
|
192
|
+
initialVals[req.key] = savedEnv[req.key] || req.defaultValue || "";
|
|
193
|
+
}
|
|
194
|
+
// Replace envValues wholesale — clear any keys from a previous store selection.
|
|
195
|
+
setEnvValues(initialVals);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}, [vercelModalOpen, envModalOpen, sshModalOpen, serverId]);
|
|
199
|
+
|
|
200
|
+
const handleSaveEnv = async () => {
|
|
201
|
+
if (apiKey) {
|
|
202
|
+
localStorage.setItem("rag_server_api_key", apiKey);
|
|
203
|
+
} else {
|
|
204
|
+
localStorage.removeItem("rag_server_api_key");
|
|
205
|
+
}
|
|
206
|
+
localStorage.setItem("rag_server_env_vars", JSON.stringify(envValues));
|
|
207
|
+
setEnvModalOpen(false);
|
|
208
|
+
|
|
209
|
+
// Check if the local server is running — if so, restart it with the new key.
|
|
210
|
+
try {
|
|
211
|
+
const statusRes = await fetch("/api/server/local");
|
|
212
|
+
const { state } = await statusRes.json();
|
|
213
|
+
|
|
214
|
+
if (state?.running) {
|
|
215
|
+
toast.loading("Restarting local server with new API key…", {
|
|
216
|
+
id: "restart",
|
|
217
|
+
});
|
|
218
|
+
const restartRes = await fetch("/api/server/local", {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
body: JSON.stringify({ action: "start", serverApiKey: apiKey || "" }),
|
|
222
|
+
});
|
|
223
|
+
const body = await restartRes.json();
|
|
224
|
+
if (body.state?.running) {
|
|
225
|
+
toast.success("Local server restarted with new API key.", {
|
|
226
|
+
id: "restart",
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
toast.error(
|
|
230
|
+
body.state?.lastError ?? "Restart failed — relaunch manually.",
|
|
231
|
+
{ id: "restart" },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
toast.success("Environment configuration saved.");
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
toast.success("Environment configuration saved.");
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const handleClearToken = () => {
|
|
243
|
+
localStorage.removeItem(GLOBAL_TOKEN_KEY);
|
|
244
|
+
setVercelToken("");
|
|
245
|
+
setTokenWasSaved(false);
|
|
246
|
+
toast.info("Vercel token cleared.");
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const handleSSHDeploy = () => {
|
|
250
|
+
setSshLogs([]);
|
|
251
|
+
setSshModalOpen(true);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const handleDownload = (provider: string) => {
|
|
255
|
+
toast.success(`Downloading deployment package for ${provider}...`);
|
|
256
|
+
window.open(`/api/server/download?provider=${provider}`);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const handleGenerateKey = () => {
|
|
260
|
+
const randomKey =
|
|
261
|
+
"larkup-" +
|
|
262
|
+
Math.random().toString(36).substring(2, 15) +
|
|
263
|
+
Math.random().toString(36).substring(2, 15);
|
|
264
|
+
setApiKey(randomKey);
|
|
265
|
+
localStorage.setItem("rag_server_api_key", randomKey);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handleCopyKey = () => {
|
|
269
|
+
if (apiKey) {
|
|
270
|
+
navigator.clipboard.writeText(apiKey);
|
|
271
|
+
toast.success("Server API Key copied to clipboard.");
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const handleDeployToVercel = async () => {
|
|
276
|
+
if (!vercelToken || !vercelProject) {
|
|
277
|
+
toast.error("Please enter both a Vercel Token and a Project name.");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const req of requiredEnv) {
|
|
282
|
+
if (req.required && !envValues[req.key]) {
|
|
283
|
+
toast.error(
|
|
284
|
+
`Please provide a value for ${req.key}. Open "Configure Environment" to set it.`,
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setIsDeploying(true);
|
|
291
|
+
toast.info("Uploading files and triggering deployment on Vercel…");
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Merge user inputs
|
|
295
|
+
const finalEnvVars = { ...envValues };
|
|
296
|
+
if (apiKey) {
|
|
297
|
+
finalEnvVars["SERVER_API_KEY"] = apiKey;
|
|
298
|
+
localStorage.setItem("rag_server_api_key", apiKey);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const res = await deployToVercel(
|
|
302
|
+
vercelToken,
|
|
303
|
+
vercelProject,
|
|
304
|
+
serverId,
|
|
305
|
+
finalEnvVars,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (res.success) {
|
|
309
|
+
// ── Persist: token globally, project + url per-server
|
|
310
|
+
localStorage.setItem(GLOBAL_TOKEN_KEY, vercelToken);
|
|
311
|
+
localStorage.setItem(projectKey(serverId), vercelProject);
|
|
312
|
+
localStorage.setItem(deployedUrlKey(serverId), res.url!);
|
|
313
|
+
localStorage.setItem(deployedProviderKey(serverId), "vercel");
|
|
314
|
+
setTokenWasSaved(true);
|
|
315
|
+
setDeployedUrl(res.url!);
|
|
316
|
+
setDeployedProvider("vercel");
|
|
317
|
+
|
|
318
|
+
const title = res.projectCreated
|
|
319
|
+
? "Project created & files deployed!"
|
|
320
|
+
: "Server files deployed successfully!";
|
|
321
|
+
|
|
322
|
+
toast.success(
|
|
323
|
+
<div className="flex flex-col gap-1">
|
|
324
|
+
<span>{title}</span>
|
|
325
|
+
{res.url && (
|
|
326
|
+
<a
|
|
327
|
+
href={res.url}
|
|
328
|
+
target="_blank"
|
|
329
|
+
rel="noopener noreferrer"
|
|
330
|
+
className="underline font-medium hover:text-blue-500 transition-colors"
|
|
331
|
+
>
|
|
332
|
+
View on Vercel ↗
|
|
333
|
+
</a>
|
|
334
|
+
)}
|
|
335
|
+
{res.projectCreated && (
|
|
336
|
+
<span className="text-xs text-muted-foreground">
|
|
337
|
+
Your backend is now live.
|
|
338
|
+
</span>
|
|
339
|
+
)}
|
|
340
|
+
</div>,
|
|
341
|
+
);
|
|
342
|
+
setVercelModalOpen(false);
|
|
343
|
+
} else {
|
|
344
|
+
toast.error(res.error || "Failed to deploy files to Vercel.");
|
|
345
|
+
}
|
|
346
|
+
} catch (e: any) {
|
|
347
|
+
toast.error(e.message || "An unexpected error occurred.");
|
|
348
|
+
} finally {
|
|
349
|
+
setIsDeploying(false);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/** Core SSH deploy logic — optionally accepts a newPassword for expired-password retry */
|
|
354
|
+
const executeSSHDeploy = async (overrideNewPassword?: string) => {
|
|
355
|
+
setIsDeploying(true);
|
|
356
|
+
toast.info("Connecting to server and deploying via SSH...", {
|
|
357
|
+
duration: 10000,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const deployHost = pendingDeployRef.current?.host || sshHost;
|
|
361
|
+
const deployUser = pendingDeployRef.current?.username || sshUsername;
|
|
362
|
+
const deployAuth = pendingDeployRef.current?.auth || {
|
|
363
|
+
type: sshAuthType,
|
|
364
|
+
value: sshKeyOrPassword,
|
|
365
|
+
};
|
|
366
|
+
const deployEnv =
|
|
367
|
+
pendingDeployRef.current?.envVars ||
|
|
368
|
+
(() => {
|
|
369
|
+
const finalEnvVars = { ...envValues };
|
|
370
|
+
if (apiKey) {
|
|
371
|
+
finalEnvVars["SERVER_API_KEY"] = apiKey;
|
|
372
|
+
localStorage.setItem("rag_server_api_key", apiKey);
|
|
373
|
+
}
|
|
374
|
+
return finalEnvVars;
|
|
375
|
+
})();
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const bodyPayload: Record<string, any> = {
|
|
379
|
+
serverId,
|
|
380
|
+
host: deployHost,
|
|
381
|
+
username: deployUser,
|
|
382
|
+
privateKeyOrPassword: deployAuth,
|
|
383
|
+
envVars: deployEnv,
|
|
384
|
+
};
|
|
385
|
+
if (overrideNewPassword) {
|
|
386
|
+
bodyPayload.newPassword = overrideNewPassword;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const response = await fetch("/api/deploy/ssh", {
|
|
390
|
+
method: "POST",
|
|
391
|
+
headers: { "Content-Type": "application/json" },
|
|
392
|
+
body: JSON.stringify(bodyPayload),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (!response.body) throw new Error("No response from server.");
|
|
396
|
+
|
|
397
|
+
const reader = response.body.getReader();
|
|
398
|
+
const decoder = new TextDecoder();
|
|
399
|
+
let doneReading = false;
|
|
400
|
+
let buffer = "";
|
|
401
|
+
|
|
402
|
+
while (!doneReading) {
|
|
403
|
+
const { value, done } = await reader.read();
|
|
404
|
+
doneReading = done;
|
|
405
|
+
if (value) {
|
|
406
|
+
buffer += decoder.decode(value, { stream: true });
|
|
407
|
+
const parts = buffer.split("\n\n");
|
|
408
|
+
buffer = parts.pop() || "";
|
|
409
|
+
for (const part of parts) {
|
|
410
|
+
if (part.startsWith("data: ")) {
|
|
411
|
+
try {
|
|
412
|
+
const data = JSON.parse(part.slice(6));
|
|
413
|
+
if (data.type === "log") {
|
|
414
|
+
setSshLogs((prev) => [...prev, data.message]);
|
|
415
|
+
} else if (data.type === "password_change_required") {
|
|
416
|
+
// Server's password is expired — ask the user for a new one
|
|
417
|
+
pendingDeployRef.current = {
|
|
418
|
+
host: deployHost,
|
|
419
|
+
username: deployUser,
|
|
420
|
+
auth: deployAuth,
|
|
421
|
+
envVars: deployEnv,
|
|
422
|
+
};
|
|
423
|
+
setIsDeploying(false);
|
|
424
|
+
setNewSshPassword("");
|
|
425
|
+
setConfirmNewSshPassword("");
|
|
426
|
+
setPasswordChangeOpen(true);
|
|
427
|
+
return; // stop processing this stream
|
|
428
|
+
} else if (data.type === "password_changed") {
|
|
429
|
+
navigator.clipboard.writeText(data.newPassword);
|
|
430
|
+
toast.warning(
|
|
431
|
+
<div className="flex flex-col gap-1">
|
|
432
|
+
<span className="font-semibold">🔑 Password Changed</span>
|
|
433
|
+
<p className="text-xs">
|
|
434
|
+
Server password updated successfully:
|
|
435
|
+
</p>
|
|
436
|
+
<code className="block p-1 bg-background border border-border rounded font-mono text-xs select-all">
|
|
437
|
+
{data.newPassword}
|
|
438
|
+
</code>
|
|
439
|
+
<p className="text-xs text-muted-foreground">
|
|
440
|
+
Copied to clipboard. Save this!
|
|
441
|
+
</p>
|
|
442
|
+
</div>,
|
|
443
|
+
{ duration: 60000 },
|
|
444
|
+
);
|
|
445
|
+
} else if (data.type === "done") {
|
|
446
|
+
if (data.success) {
|
|
447
|
+
localStorage.setItem(deployedUrlKey(serverId), data.url!);
|
|
448
|
+
localStorage.setItem(
|
|
449
|
+
deployedProviderKey(serverId),
|
|
450
|
+
"hetzner",
|
|
451
|
+
);
|
|
452
|
+
setDeployedUrl(data.url!);
|
|
453
|
+
setDeployedProvider("hetzner");
|
|
454
|
+
|
|
455
|
+
toast.success(
|
|
456
|
+
<div className="flex flex-col gap-1">
|
|
457
|
+
<span>Server deployed via SSH successfully!</span>
|
|
458
|
+
{data.url && (
|
|
459
|
+
<a
|
|
460
|
+
href={data.url}
|
|
461
|
+
target="_blank"
|
|
462
|
+
rel="noopener noreferrer"
|
|
463
|
+
className="underline font-medium hover:text-blue-500 transition-colors"
|
|
464
|
+
>
|
|
465
|
+
View Live Deployment ↗
|
|
466
|
+
</a>
|
|
467
|
+
)}
|
|
468
|
+
{data.newPassword && (
|
|
469
|
+
<div className="mt-2 p-2 bg-black/10 dark:bg-white/10 rounded text-xs">
|
|
470
|
+
<p className="font-semibold text-destructive mb-1">
|
|
471
|
+
Password was changed during deployment
|
|
472
|
+
</p>
|
|
473
|
+
<p>Your new root password is:</p>
|
|
474
|
+
<code className="block mt-1 p-1 bg-background border border-border rounded font-mono select-all">
|
|
475
|
+
{data.newPassword}
|
|
476
|
+
</code>
|
|
477
|
+
<p className="mt-1 text-muted-foreground">
|
|
478
|
+
It has been copied to your clipboard.
|
|
479
|
+
</p>
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
482
|
+
</div>,
|
|
483
|
+
{ duration: data.newPassword ? 30000 : undefined },
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
if (data.newPassword) {
|
|
487
|
+
navigator.clipboard.writeText(data.newPassword);
|
|
488
|
+
}
|
|
489
|
+
pendingDeployRef.current = null;
|
|
490
|
+
setSshModalOpen(false);
|
|
491
|
+
} else {
|
|
492
|
+
toast.error(data.error || "Failed to deploy via SSH.");
|
|
493
|
+
}
|
|
494
|
+
} else if (data.type === "error") {
|
|
495
|
+
toast.error(data.error || "Failed to deploy via SSH.");
|
|
496
|
+
}
|
|
497
|
+
} catch (e) {}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} catch (e: any) {
|
|
503
|
+
toast.error(e.message || "An unexpected error occurred.");
|
|
504
|
+
} finally {
|
|
505
|
+
setIsDeploying(false);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const handleDeployToSSH = async () => {
|
|
510
|
+
if (!sshHost || !sshUsername || !sshKeyOrPassword) {
|
|
511
|
+
toast.error("Please fill in all SSH connection details.");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
for (const req of requiredEnv) {
|
|
516
|
+
if (req.required && !envValues[req.key]) {
|
|
517
|
+
toast.error(`Please provide a value for ${req.key}.`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
setSshLogs([]);
|
|
523
|
+
pendingDeployRef.current = null;
|
|
524
|
+
await executeSSHDeploy();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/** Called when the user submits the password-change dialog */
|
|
528
|
+
const handlePasswordChangeAndRedeploy = async () => {
|
|
529
|
+
if (!newSshPassword) {
|
|
530
|
+
toast.error("Please enter a new password.");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (newSshPassword !== confirmNewSshPassword) {
|
|
534
|
+
toast.error("Passwords do not match.");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (newSshPassword.length < 8) {
|
|
538
|
+
toast.error("Password must be at least 8 characters.");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
setPasswordChangeOpen(false);
|
|
542
|
+
setSshLogs((prev) => [...prev, "", "--- Retrying with new password ---"]);
|
|
543
|
+
await executeSSHDeploy(newSshPassword);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<div className="flex items-center gap-2">
|
|
548
|
+
<TooltipProvider>
|
|
549
|
+
<Tooltip>
|
|
550
|
+
<TooltipTrigger
|
|
551
|
+
delay={0}
|
|
552
|
+
render={
|
|
553
|
+
<Button
|
|
554
|
+
variant="outline"
|
|
555
|
+
size="icon"
|
|
556
|
+
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground"
|
|
557
|
+
onClick={() => setEnvModalOpen(true)}
|
|
558
|
+
>
|
|
559
|
+
<Settings2 className="size-4" />
|
|
560
|
+
</Button>
|
|
561
|
+
}
|
|
562
|
+
/>
|
|
563
|
+
<TooltipContent>
|
|
564
|
+
<p>Environment Variables</p>
|
|
565
|
+
</TooltipContent>
|
|
566
|
+
</Tooltip>
|
|
567
|
+
</TooltipProvider>
|
|
568
|
+
<Button
|
|
569
|
+
variant="outline"
|
|
570
|
+
className="h-9 gap-1.5 bg-muted/80 text-xs text-black/80 hover:text-black hover:bg-muted "
|
|
571
|
+
onClick={() => {
|
|
572
|
+
const currentKey =
|
|
573
|
+
apiKey || localStorage.getItem("rag_server_api_key");
|
|
574
|
+
if (currentKey) {
|
|
575
|
+
navigator.clipboard.writeText(currentKey);
|
|
576
|
+
toast.success("Server API Key copied to clipboard.");
|
|
577
|
+
} else {
|
|
578
|
+
toast.error("No Server API Key is set.");
|
|
579
|
+
}
|
|
580
|
+
}}
|
|
581
|
+
title="Copy Server API Key"
|
|
582
|
+
>
|
|
583
|
+
<KeyRound className="size-3.5" />
|
|
584
|
+
Copy API Key
|
|
585
|
+
</Button>
|
|
586
|
+
|
|
587
|
+
<DropdownMenu>
|
|
588
|
+
<DropdownMenuTrigger
|
|
589
|
+
render={
|
|
590
|
+
<Button
|
|
591
|
+
variant="default"
|
|
592
|
+
size="default"
|
|
593
|
+
className="h-9 w-[130px] justify-start gap-1"
|
|
594
|
+
/>
|
|
595
|
+
}
|
|
596
|
+
>
|
|
597
|
+
<div className="w-full flex items-center justify-between">
|
|
598
|
+
<div className="flex items-center">
|
|
599
|
+
<Rocket className="size-4 mr-2" />
|
|
600
|
+
Deploy
|
|
601
|
+
</div>
|
|
602
|
+
<ChevronDown />
|
|
603
|
+
</div>
|
|
604
|
+
</DropdownMenuTrigger>
|
|
605
|
+
<DropdownMenuContent align="end" className={"w-[200px]"}>
|
|
606
|
+
<DropdownMenuItem onClick={() => setVercelModalOpen(true)}>
|
|
607
|
+
<p className="flex items-center">
|
|
608
|
+
<img src={"/vercel.svg"} className="size-4 mr-2" alt="vercel" />
|
|
609
|
+
Vercel
|
|
610
|
+
</p>
|
|
611
|
+
</DropdownMenuItem>
|
|
612
|
+
<DropdownMenuItem onClick={handleSSHDeploy}>
|
|
613
|
+
<p className="flex items-center">
|
|
614
|
+
<img src={"/hetzner.svg"} className="size-4 mr-2" alt="vercel" />
|
|
615
|
+
Hetzner
|
|
616
|
+
</p>
|
|
617
|
+
</DropdownMenuItem>
|
|
618
|
+
<DropdownMenuSub>
|
|
619
|
+
<DropdownMenuSubTrigger>
|
|
620
|
+
<img src="/icons/azure.svg" className="size-4 mr-2" alt="Azure" />
|
|
621
|
+
Azure
|
|
622
|
+
</DropdownMenuSubTrigger>
|
|
623
|
+
<DropdownMenuSubContent className="w-[240px]">
|
|
624
|
+
<DropdownMenuItem
|
|
625
|
+
disabled
|
|
626
|
+
className="h-auto"
|
|
627
|
+
onClick={() => handleDownload("azure-app-service")}
|
|
628
|
+
>
|
|
629
|
+
<div className="flex flex-col gap-0.5">
|
|
630
|
+
<span>App Service</span>
|
|
631
|
+
<span className="text-[10px] text-muted-foreground">
|
|
632
|
+
Managed PaaS
|
|
633
|
+
</span>
|
|
634
|
+
</div>
|
|
635
|
+
</DropdownMenuItem>
|
|
636
|
+
<DropdownMenuItem
|
|
637
|
+
disabled
|
|
638
|
+
className="h-auto"
|
|
639
|
+
onClick={() => handleDownload("azure-container-apps")}
|
|
640
|
+
>
|
|
641
|
+
<div className="flex flex-col gap-0.5">
|
|
642
|
+
<span>Container Apps</span>
|
|
643
|
+
<span className="text-[10px] text-muted-foreground">
|
|
644
|
+
Serverless containers
|
|
645
|
+
</span>
|
|
646
|
+
</div>
|
|
647
|
+
</DropdownMenuItem>
|
|
648
|
+
<DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
|
|
649
|
+
<div className="flex flex-col gap-0.5">
|
|
650
|
+
<span>Virtual Machines</span>
|
|
651
|
+
<span className="text-[10px] text-muted-foreground">
|
|
652
|
+
Full IaaS
|
|
653
|
+
</span>
|
|
654
|
+
</div>
|
|
655
|
+
</DropdownMenuItem>
|
|
656
|
+
</DropdownMenuSubContent>
|
|
657
|
+
</DropdownMenuSub>
|
|
658
|
+
|
|
659
|
+
<DropdownMenuSub>
|
|
660
|
+
<DropdownMenuSubTrigger>
|
|
661
|
+
<img src="/icons/aws.svg" className="size-4 mr-2" alt="AWS" />
|
|
662
|
+
AWS
|
|
663
|
+
</DropdownMenuSubTrigger>
|
|
664
|
+
<DropdownMenuSubContent className="w-[240px] ">
|
|
665
|
+
<DropdownMenuItem
|
|
666
|
+
disabled
|
|
667
|
+
className="h-auto"
|
|
668
|
+
onClick={() => handleDownload("aws-elastic-beanstalk")}
|
|
669
|
+
>
|
|
670
|
+
<div className="flex flex-col gap-0.5">
|
|
671
|
+
<span>Elastic Beanstalk</span>
|
|
672
|
+
<span className="text-[10px] text-muted-foreground">
|
|
673
|
+
Managed PaaS
|
|
674
|
+
</span>
|
|
675
|
+
</div>
|
|
676
|
+
</DropdownMenuItem>
|
|
677
|
+
<DropdownMenuItem
|
|
678
|
+
disabled
|
|
679
|
+
className="h-auto"
|
|
680
|
+
onClick={() => handleDownload("aws-app-runner")}
|
|
681
|
+
>
|
|
682
|
+
<div className="flex flex-col gap-0.5">
|
|
683
|
+
<span>App Runner / Fargate</span>
|
|
684
|
+
<span className="text-[10px] text-muted-foreground">
|
|
685
|
+
Serverless containers
|
|
686
|
+
</span>
|
|
687
|
+
</div>
|
|
688
|
+
</DropdownMenuItem>
|
|
689
|
+
<DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
|
|
690
|
+
<div className="flex flex-col gap-0.5">
|
|
691
|
+
<span>EC2</span>
|
|
692
|
+
<span className="text-[10px] text-muted-foreground">
|
|
693
|
+
Full IaaS
|
|
694
|
+
</span>
|
|
695
|
+
</div>
|
|
696
|
+
</DropdownMenuItem>
|
|
697
|
+
</DropdownMenuSubContent>
|
|
698
|
+
</DropdownMenuSub>
|
|
699
|
+
|
|
700
|
+
<DropdownMenuSub>
|
|
701
|
+
<DropdownMenuSubTrigger>
|
|
702
|
+
<img src="/icons/gcp.svg" className="size-4 mr-2" alt="GCP" />
|
|
703
|
+
GCP
|
|
704
|
+
</DropdownMenuSubTrigger>
|
|
705
|
+
<DropdownMenuSubContent className="w-[240px] ">
|
|
706
|
+
<DropdownMenuItem
|
|
707
|
+
disabled
|
|
708
|
+
className="h-auto"
|
|
709
|
+
onClick={() => handleDownload("gcp-app-engine")}
|
|
710
|
+
>
|
|
711
|
+
<div className="flex flex-col gap-0.5">
|
|
712
|
+
<span>App Engine</span>
|
|
713
|
+
<span className="text-[10px] text-muted-foreground">
|
|
714
|
+
Managed PaaS
|
|
715
|
+
</span>
|
|
716
|
+
</div>
|
|
717
|
+
</DropdownMenuItem>
|
|
718
|
+
<DropdownMenuItem
|
|
719
|
+
disabled
|
|
720
|
+
className="h-auto"
|
|
721
|
+
onClick={() => handleDownload("gcp-cloud-run")}
|
|
722
|
+
>
|
|
723
|
+
<div className="flex flex-col gap-0.5">
|
|
724
|
+
<span>Cloud Run</span>
|
|
725
|
+
<span className="text-[10px] text-muted-foreground">
|
|
726
|
+
Serverless containers
|
|
727
|
+
</span>
|
|
728
|
+
</div>
|
|
729
|
+
</DropdownMenuItem>
|
|
730
|
+
<DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
|
|
731
|
+
<div className="flex flex-col gap-0.5">
|
|
732
|
+
<span>Compute Engine</span>
|
|
733
|
+
<span className="text-[10px] text-muted-foreground">
|
|
734
|
+
Full IaaS
|
|
735
|
+
</span>
|
|
736
|
+
</div>
|
|
737
|
+
</DropdownMenuItem>
|
|
738
|
+
</DropdownMenuSubContent>
|
|
739
|
+
</DropdownMenuSub>
|
|
740
|
+
|
|
741
|
+
<DropdownMenuSub>
|
|
742
|
+
<DropdownMenuSubTrigger>
|
|
743
|
+
<img
|
|
744
|
+
src="/icons/digital-ocean.webp"
|
|
745
|
+
className="size-4 mr-2"
|
|
746
|
+
alt="DigitalOcean"
|
|
747
|
+
/>
|
|
748
|
+
DigitalOcean
|
|
749
|
+
</DropdownMenuSubTrigger>
|
|
750
|
+
<DropdownMenuSubContent className="w-[240px] ">
|
|
751
|
+
<DropdownMenuItem
|
|
752
|
+
disabled
|
|
753
|
+
className="h-auto"
|
|
754
|
+
onClick={() => handleDownload("do-app-platform")}
|
|
755
|
+
>
|
|
756
|
+
<div className="flex flex-col gap-0.5">
|
|
757
|
+
<span>App Platform</span>
|
|
758
|
+
<span className="text-[10px] text-muted-foreground">
|
|
759
|
+
Managed PaaS / Containers
|
|
760
|
+
</span>
|
|
761
|
+
</div>
|
|
762
|
+
</DropdownMenuItem>
|
|
763
|
+
<DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
|
|
764
|
+
<div className="flex flex-col gap-0.5">
|
|
765
|
+
<span>Droplets</span>
|
|
766
|
+
<span className="text-[10px] text-muted-foreground">
|
|
767
|
+
Full IaaS
|
|
768
|
+
</span>
|
|
769
|
+
</div>
|
|
770
|
+
</DropdownMenuItem>
|
|
771
|
+
</DropdownMenuSubContent>
|
|
772
|
+
</DropdownMenuSub>
|
|
773
|
+
</DropdownMenuContent>
|
|
774
|
+
</DropdownMenu>
|
|
775
|
+
|
|
776
|
+
{/* ── Main Deploy Modal ── */}
|
|
777
|
+
<Dialog open={vercelModalOpen} onOpenChange={setVercelModalOpen}>
|
|
778
|
+
<DialogContent className="max-w-md">
|
|
779
|
+
<DialogHeader>
|
|
780
|
+
<DialogTitle>Deploy to Vercel</DialogTitle>
|
|
781
|
+
<DialogDescription>
|
|
782
|
+
Configure your Vercel project and deploy your RAG server.
|
|
783
|
+
</DialogDescription>
|
|
784
|
+
</DialogHeader>
|
|
785
|
+
|
|
786
|
+
<div className="grid gap-5 py-4">
|
|
787
|
+
{/* ── Token ── */}
|
|
788
|
+
<div className="grid gap-2">
|
|
789
|
+
<div className="flex items-center justify-between">
|
|
790
|
+
<div className="flex items-center gap-2">
|
|
791
|
+
<Label htmlFor="vercel-token">Vercel Access Token</Label>
|
|
792
|
+
{tokenWasSaved && (
|
|
793
|
+
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
|
794
|
+
<CheckCircle2 className="size-3" />
|
|
795
|
+
Saved
|
|
796
|
+
</span>
|
|
797
|
+
)}
|
|
798
|
+
</div>
|
|
799
|
+
<div className="flex items-center gap-2">
|
|
800
|
+
<a
|
|
801
|
+
href="https://vercel.com/account/tokens"
|
|
802
|
+
target="_blank"
|
|
803
|
+
rel="noopener noreferrer"
|
|
804
|
+
className="text-xs text-muted-foreground underline hover:text-foreground transition-colors"
|
|
805
|
+
>
|
|
806
|
+
Get token ↗
|
|
807
|
+
</a>
|
|
808
|
+
{tokenWasSaved && (
|
|
809
|
+
<button
|
|
810
|
+
type="button"
|
|
811
|
+
onClick={handleClearToken}
|
|
812
|
+
className="text-xs text-destructive underline hover:opacity-80 transition-opacity"
|
|
813
|
+
>
|
|
814
|
+
Clear
|
|
815
|
+
</button>
|
|
816
|
+
)}
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
|
|
820
|
+
<div className="relative">
|
|
821
|
+
<Input
|
|
822
|
+
id="vercel-token"
|
|
823
|
+
type={showToken ? "text" : "password"}
|
|
824
|
+
placeholder="Enter your Vercel Access Token"
|
|
825
|
+
value={vercelToken}
|
|
826
|
+
onChange={(e) => {
|
|
827
|
+
setVercelToken(e.target.value);
|
|
828
|
+
setTokenWasSaved(false);
|
|
829
|
+
}}
|
|
830
|
+
className="pr-9"
|
|
831
|
+
/>
|
|
832
|
+
<button
|
|
833
|
+
type="button"
|
|
834
|
+
onClick={() => setShowToken((v) => !v)}
|
|
835
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
836
|
+
tabIndex={-1}
|
|
837
|
+
>
|
|
838
|
+
{showToken ? (
|
|
839
|
+
<EyeOff className="size-4" />
|
|
840
|
+
) : (
|
|
841
|
+
<Eye className="size-4" />
|
|
842
|
+
)}
|
|
843
|
+
</button>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
|
|
847
|
+
{/* ── Project ── */}
|
|
848
|
+
<div className="grid gap-2">
|
|
849
|
+
<Label htmlFor="vercel-project">
|
|
850
|
+
Project ID or Name
|
|
851
|
+
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
|
852
|
+
(saved per server)
|
|
853
|
+
</span>
|
|
854
|
+
</Label>
|
|
855
|
+
<Input
|
|
856
|
+
id="vercel-project"
|
|
857
|
+
placeholder="my-rag-server"
|
|
858
|
+
value={vercelProject}
|
|
859
|
+
onChange={(e) => setVercelProject(e.target.value)}
|
|
860
|
+
/>
|
|
861
|
+
</div>
|
|
862
|
+
|
|
863
|
+
{/* ── Environment Variables Summary + Button ── */}
|
|
864
|
+
<div className="border-t border-border pt-4 mt-1">
|
|
865
|
+
<div className="flex items-center justify-between">
|
|
866
|
+
<div className="flex items-center gap-2">
|
|
867
|
+
<h4 className="text-sm font-medium">Environment Variables</h4>
|
|
868
|
+
{envSummary.filled > 0 && (
|
|
869
|
+
<Badge
|
|
870
|
+
variant="secondary"
|
|
871
|
+
className="text-[11px] font-normal tabular-nums"
|
|
872
|
+
>
|
|
873
|
+
{envSummary.filled}/{envSummary.total} configured
|
|
874
|
+
</Badge>
|
|
875
|
+
)}
|
|
876
|
+
{envSummary.requiredMissing > 0 && (
|
|
877
|
+
<Badge
|
|
878
|
+
variant="destructive"
|
|
879
|
+
className="text-[11px] font-normal"
|
|
880
|
+
>
|
|
881
|
+
{envSummary.requiredMissing} required
|
|
882
|
+
</Badge>
|
|
883
|
+
)}
|
|
884
|
+
{envSummary.requiredMissing === 0 &&
|
|
885
|
+
envSummary.filled > 0 && (
|
|
886
|
+
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
|
887
|
+
)}
|
|
888
|
+
</div>
|
|
889
|
+
<Button
|
|
890
|
+
variant="outline"
|
|
891
|
+
size="sm"
|
|
892
|
+
className="h-6 text-[11px] gap-1.5"
|
|
893
|
+
onClick={() => setEnvModalOpen(true)}
|
|
894
|
+
>
|
|
895
|
+
<Settings2 className="size-3.5" />
|
|
896
|
+
Configure
|
|
897
|
+
</Button>
|
|
898
|
+
</div>
|
|
899
|
+
<p className="text-xs text-muted-foreground mt-1.5">
|
|
900
|
+
API keys and settings your deployed server needs to run.
|
|
901
|
+
</p>
|
|
902
|
+
</div>
|
|
903
|
+
|
|
904
|
+
{/* ── Current deployed URL ── */}
|
|
905
|
+
{deployedUrl && (
|
|
906
|
+
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
|
|
907
|
+
<VercelIcon className="size-3 shrink-0 text-foreground" />
|
|
908
|
+
<span className="text-muted-foreground">
|
|
909
|
+
Currently deployed at:
|
|
910
|
+
</span>
|
|
911
|
+
<a
|
|
912
|
+
href={deployedUrl}
|
|
913
|
+
target="_blank"
|
|
914
|
+
rel="noopener noreferrer"
|
|
915
|
+
className="font-mono font-medium text-foreground hover:underline truncate"
|
|
916
|
+
>
|
|
917
|
+
{deployedUrl.replace(/^https?:\/\//, "")}
|
|
918
|
+
</a>
|
|
919
|
+
</div>
|
|
920
|
+
)}
|
|
921
|
+
</div>
|
|
922
|
+
|
|
923
|
+
<DialogFooter>
|
|
924
|
+
<Button
|
|
925
|
+
variant="outline"
|
|
926
|
+
onClick={() => setVercelModalOpen(false)}
|
|
927
|
+
disabled={isDeploying}
|
|
928
|
+
>
|
|
929
|
+
Cancel
|
|
930
|
+
</Button>
|
|
931
|
+
<Button onClick={handleDeployToVercel} disabled={isDeploying}>
|
|
932
|
+
{isDeploying && <Loader2 className="mr-2 size-4 animate-spin" />}
|
|
933
|
+
Deploy
|
|
934
|
+
</Button>
|
|
935
|
+
</DialogFooter>
|
|
936
|
+
</DialogContent>
|
|
937
|
+
</Dialog>
|
|
938
|
+
|
|
939
|
+
{/* ── Main SSH Modal ── */}
|
|
940
|
+
<Dialog open={sshModalOpen} onOpenChange={setSshModalOpen}>
|
|
941
|
+
<DialogContent className="max-w-md">
|
|
942
|
+
<DialogHeader>
|
|
943
|
+
<DialogTitle>Deploy via SSH (Hetzner, EC2, Droplets)</DialogTitle>
|
|
944
|
+
<DialogDescription>
|
|
945
|
+
BuddyHere will automatically connect, install Docker, and launch
|
|
946
|
+
your server.
|
|
947
|
+
</DialogDescription>
|
|
948
|
+
</DialogHeader>
|
|
949
|
+
|
|
950
|
+
<div className="grid gap-5 py-4">
|
|
951
|
+
<div className="grid gap-2">
|
|
952
|
+
<Label htmlFor="ssh-host">Server IP / Hostname</Label>
|
|
953
|
+
<Input
|
|
954
|
+
id="ssh-host"
|
|
955
|
+
placeholder="e.g. 192.168.1.1 or myserver.com"
|
|
956
|
+
value={sshHost}
|
|
957
|
+
onChange={(e) => setSshHost(e.target.value)}
|
|
958
|
+
/>
|
|
959
|
+
</div>
|
|
960
|
+
|
|
961
|
+
<div className="grid gap-2">
|
|
962
|
+
<Label htmlFor="ssh-user">SSH Username</Label>
|
|
963
|
+
<Input
|
|
964
|
+
id="ssh-user"
|
|
965
|
+
placeholder="root"
|
|
966
|
+
value={sshUsername}
|
|
967
|
+
onChange={(e) => setSshUsername(e.target.value)}
|
|
968
|
+
/>
|
|
969
|
+
</div>
|
|
970
|
+
|
|
971
|
+
<div className="grid gap-2">
|
|
972
|
+
<div className="flex items-center justify-between">
|
|
973
|
+
<Label>Authentication Type</Label>
|
|
974
|
+
<div className="flex gap-2">
|
|
975
|
+
<Badge
|
|
976
|
+
variant={sshAuthType === "key" ? "default" : "outline"}
|
|
977
|
+
className="cursor-pointer"
|
|
978
|
+
onClick={() => setSshAuthType("key")}
|
|
979
|
+
>
|
|
980
|
+
Private Key
|
|
981
|
+
</Badge>
|
|
982
|
+
<Badge
|
|
983
|
+
variant={sshAuthType === "password" ? "default" : "outline"}
|
|
984
|
+
className="cursor-pointer"
|
|
985
|
+
onClick={() => setSshAuthType("password")}
|
|
986
|
+
>
|
|
987
|
+
Password
|
|
988
|
+
</Badge>
|
|
989
|
+
</div>
|
|
990
|
+
</div>
|
|
991
|
+
|
|
992
|
+
{sshAuthType === "key" ? (
|
|
993
|
+
<textarea
|
|
994
|
+
className="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
|
995
|
+
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----\n..."
|
|
996
|
+
value={sshKeyOrPassword}
|
|
997
|
+
onChange={(e) => setSshKeyOrPassword(e.target.value)}
|
|
998
|
+
/>
|
|
999
|
+
) : (
|
|
1000
|
+
<div className="relative">
|
|
1001
|
+
<Input
|
|
1002
|
+
type={showSshPassword ? "text" : "password"}
|
|
1003
|
+
placeholder="SSH Password"
|
|
1004
|
+
value={sshKeyOrPassword}
|
|
1005
|
+
onChange={(e) => setSshKeyOrPassword(e.target.value)}
|
|
1006
|
+
className="pr-10"
|
|
1007
|
+
/>
|
|
1008
|
+
<Button
|
|
1009
|
+
type="button"
|
|
1010
|
+
variant="ghost"
|
|
1011
|
+
size="sm"
|
|
1012
|
+
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
1013
|
+
onClick={() => setShowSshPassword(!showSshPassword)}
|
|
1014
|
+
>
|
|
1015
|
+
{showSshPassword ? (
|
|
1016
|
+
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
1017
|
+
) : (
|
|
1018
|
+
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
1019
|
+
)}
|
|
1020
|
+
</Button>
|
|
1021
|
+
</div>
|
|
1022
|
+
)}
|
|
1023
|
+
</div>
|
|
1024
|
+
|
|
1025
|
+
{isDeploying && sshLogs.length > 0 && (
|
|
1026
|
+
<div className="mt-2 bg-black text-[#00ff00] font-mono text-[10px] p-3 rounded-md h-[200px] overflow-y-auto break-all whitespace-pre-wrap leading-tight shadow-inner flex flex-col-reverse">
|
|
1027
|
+
<div>
|
|
1028
|
+
{sshLogs.map((log, i) => (
|
|
1029
|
+
<div key={i}>{log}</div>
|
|
1030
|
+
))}
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
)}
|
|
1034
|
+
|
|
1035
|
+
{/* ── Environment Variables Summary + Button ── */}
|
|
1036
|
+
<div className="border-t border-border pt-4 mt-1">
|
|
1037
|
+
<div className="flex items-center justify-between">
|
|
1038
|
+
<div className="flex items-center gap-2">
|
|
1039
|
+
<h4 className="text-sm font-medium">Environment Variables</h4>
|
|
1040
|
+
{envSummary.filled > 0 && (
|
|
1041
|
+
<Badge
|
|
1042
|
+
variant="secondary"
|
|
1043
|
+
className="text-[11px] font-normal tabular-nums"
|
|
1044
|
+
>
|
|
1045
|
+
{envSummary.filled}/{envSummary.total} configured
|
|
1046
|
+
</Badge>
|
|
1047
|
+
)}
|
|
1048
|
+
{envSummary.requiredMissing > 0 && (
|
|
1049
|
+
<Badge
|
|
1050
|
+
variant="destructive"
|
|
1051
|
+
className="text-[11px] font-normal"
|
|
1052
|
+
>
|
|
1053
|
+
{envSummary.requiredMissing} required
|
|
1054
|
+
</Badge>
|
|
1055
|
+
)}
|
|
1056
|
+
{envSummary.requiredMissing === 0 &&
|
|
1057
|
+
envSummary.filled > 0 && (
|
|
1058
|
+
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
|
1059
|
+
)}
|
|
1060
|
+
</div>
|
|
1061
|
+
<Button
|
|
1062
|
+
variant="outline"
|
|
1063
|
+
size="sm"
|
|
1064
|
+
className="h-6 text-[11px] gap-1.5"
|
|
1065
|
+
onClick={() => setEnvModalOpen(true)}
|
|
1066
|
+
>
|
|
1067
|
+
<Settings2 className="size-3.5" />
|
|
1068
|
+
Configure
|
|
1069
|
+
</Button>
|
|
1070
|
+
</div>
|
|
1071
|
+
</div>
|
|
1072
|
+
</div>
|
|
1073
|
+
|
|
1074
|
+
<DialogFooter>
|
|
1075
|
+
<Button
|
|
1076
|
+
variant="outline"
|
|
1077
|
+
onClick={() => setSshModalOpen(false)}
|
|
1078
|
+
disabled={isDeploying}
|
|
1079
|
+
>
|
|
1080
|
+
Cancel
|
|
1081
|
+
</Button>
|
|
1082
|
+
<Button onClick={handleDeployToSSH} disabled={isDeploying}>
|
|
1083
|
+
{isDeploying && <Loader2 className="mr-2 size-4 animate-spin" />}
|
|
1084
|
+
Deploy via SSH
|
|
1085
|
+
</Button>
|
|
1086
|
+
</DialogFooter>
|
|
1087
|
+
</DialogContent>
|
|
1088
|
+
</Dialog>
|
|
1089
|
+
|
|
1090
|
+
{/* ── Password Change Dialog (Hetzner first-login) ── */}
|
|
1091
|
+
<Dialog
|
|
1092
|
+
open={passwordChangeOpen}
|
|
1093
|
+
onOpenChange={(open) => {
|
|
1094
|
+
if (!open) pendingDeployRef.current = null;
|
|
1095
|
+
setPasswordChangeOpen(open);
|
|
1096
|
+
}}
|
|
1097
|
+
>
|
|
1098
|
+
<DialogContent className="max-w-sm">
|
|
1099
|
+
<DialogHeader>
|
|
1100
|
+
<div className="flex items-center gap-2 mb-1">
|
|
1101
|
+
<div className="flex items-center justify-center size-9 rounded-full bg-amber-500/10 text-amber-500">
|
|
1102
|
+
<ShieldAlert className="size-4.5" />
|
|
1103
|
+
</div>
|
|
1104
|
+
<DialogTitle>Password Change Required</DialogTitle>
|
|
1105
|
+
</div>
|
|
1106
|
+
<DialogDescription>
|
|
1107
|
+
Your server requires a password change on first login. Please set
|
|
1108
|
+
a new root password to continue the deployment.
|
|
1109
|
+
</DialogDescription>
|
|
1110
|
+
</DialogHeader>
|
|
1111
|
+
|
|
1112
|
+
<div className="grid gap-4 py-3">
|
|
1113
|
+
<div className="grid gap-2">
|
|
1114
|
+
<Label htmlFor="new-ssh-password">New Password</Label>
|
|
1115
|
+
<div className="relative">
|
|
1116
|
+
<Input
|
|
1117
|
+
id="new-ssh-password"
|
|
1118
|
+
type={showNewSshPassword ? "text" : "password"}
|
|
1119
|
+
placeholder="Enter new root password"
|
|
1120
|
+
value={newSshPassword}
|
|
1121
|
+
onChange={(e) => setNewSshPassword(e.target.value)}
|
|
1122
|
+
className="pr-10"
|
|
1123
|
+
autoFocus
|
|
1124
|
+
/>
|
|
1125
|
+
<Button
|
|
1126
|
+
type="button"
|
|
1127
|
+
variant="ghost"
|
|
1128
|
+
size="sm"
|
|
1129
|
+
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
1130
|
+
onClick={() => setShowNewSshPassword(!showNewSshPassword)}
|
|
1131
|
+
>
|
|
1132
|
+
{showNewSshPassword ? (
|
|
1133
|
+
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
1134
|
+
) : (
|
|
1135
|
+
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
1136
|
+
)}
|
|
1137
|
+
</Button>
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
|
|
1141
|
+
<div className="grid gap-2">
|
|
1142
|
+
<Label htmlFor="confirm-ssh-password">Confirm Password</Label>
|
|
1143
|
+
<div className="relative">
|
|
1144
|
+
<Input
|
|
1145
|
+
id="confirm-ssh-password"
|
|
1146
|
+
type={showConfirmSshPassword ? "text" : "password"}
|
|
1147
|
+
placeholder="Re-enter new password"
|
|
1148
|
+
value={confirmNewSshPassword}
|
|
1149
|
+
onChange={(e) => setConfirmNewSshPassword(e.target.value)}
|
|
1150
|
+
className="pr-10"
|
|
1151
|
+
onKeyDown={(e) => {
|
|
1152
|
+
if (e.key === "Enter") handlePasswordChangeAndRedeploy();
|
|
1153
|
+
}}
|
|
1154
|
+
/>
|
|
1155
|
+
<Button
|
|
1156
|
+
type="button"
|
|
1157
|
+
variant="ghost"
|
|
1158
|
+
size="sm"
|
|
1159
|
+
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
1160
|
+
onClick={() =>
|
|
1161
|
+
setShowConfirmSshPassword(!showConfirmSshPassword)
|
|
1162
|
+
}
|
|
1163
|
+
>
|
|
1164
|
+
{showConfirmSshPassword ? (
|
|
1165
|
+
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
1166
|
+
) : (
|
|
1167
|
+
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
1168
|
+
)}
|
|
1169
|
+
</Button>
|
|
1170
|
+
</div>
|
|
1171
|
+
</div>
|
|
1172
|
+
|
|
1173
|
+
{newSshPassword &&
|
|
1174
|
+
confirmNewSshPassword &&
|
|
1175
|
+
newSshPassword !== confirmNewSshPassword && (
|
|
1176
|
+
<p className="text-xs text-destructive">
|
|
1177
|
+
Passwords do not match.
|
|
1178
|
+
</p>
|
|
1179
|
+
)}
|
|
1180
|
+
|
|
1181
|
+
<p className="text-xs text-muted-foreground">
|
|
1182
|
+
This is common for first-time logins on Hetzner, AWS EC2, and
|
|
1183
|
+
similar providers. Your chosen password will be used to continue
|
|
1184
|
+
deployment.
|
|
1185
|
+
</p>
|
|
1186
|
+
</div>
|
|
1187
|
+
|
|
1188
|
+
<DialogFooter>
|
|
1189
|
+
<Button
|
|
1190
|
+
variant="outline"
|
|
1191
|
+
onClick={() => {
|
|
1192
|
+
setPasswordChangeOpen(false);
|
|
1193
|
+
pendingDeployRef.current = null;
|
|
1194
|
+
}}
|
|
1195
|
+
>
|
|
1196
|
+
Cancel
|
|
1197
|
+
</Button>
|
|
1198
|
+
<Button
|
|
1199
|
+
onClick={handlePasswordChangeAndRedeploy}
|
|
1200
|
+
disabled={
|
|
1201
|
+
!newSshPassword ||
|
|
1202
|
+
!confirmNewSshPassword ||
|
|
1203
|
+
newSshPassword !== confirmNewSshPassword
|
|
1204
|
+
}
|
|
1205
|
+
>
|
|
1206
|
+
Change Password & Deploy
|
|
1207
|
+
</Button>
|
|
1208
|
+
</DialogFooter>
|
|
1209
|
+
</DialogContent>
|
|
1210
|
+
</Dialog>
|
|
1211
|
+
|
|
1212
|
+
{/* ── Environment Variables Sheet (nested) ── */}
|
|
1213
|
+
<Sheet open={envModalOpen} onOpenChange={setEnvModalOpen}>
|
|
1214
|
+
<SheetContent className="max-w-lg overflow-y-auto sm:max-w-xl! w-full p-1 px-3 pb-0 mb-0">
|
|
1215
|
+
<SheetHeader>
|
|
1216
|
+
<SheetTitle>Environment Variables</SheetTitle>
|
|
1217
|
+
<SheetDescription>
|
|
1218
|
+
Configure the API keys and settings your RAG server needs. Values
|
|
1219
|
+
from your workspace{" "}
|
|
1220
|
+
<code className="font-mono text-[13px]">.env</code> are pre-filled
|
|
1221
|
+
automatically.
|
|
1222
|
+
</SheetDescription>
|
|
1223
|
+
</SheetHeader>
|
|
1224
|
+
|
|
1225
|
+
<div className="grid gap-5 py-6">
|
|
1226
|
+
{/* ── SERVER_API_KEY (with generate / copy) ── */}
|
|
1227
|
+
<div className="grid gap-2">
|
|
1228
|
+
<Label htmlFor="server-api-key">
|
|
1229
|
+
SERVER_API_KEY
|
|
1230
|
+
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
|
1231
|
+
(Optional — secures your deployed endpoints)
|
|
1232
|
+
</span>
|
|
1233
|
+
</Label>
|
|
1234
|
+
<div className="flex gap-2">
|
|
1235
|
+
<div className="relative flex-1">
|
|
1236
|
+
<Input
|
|
1237
|
+
id="server-api-key"
|
|
1238
|
+
type={showApiKey ? "text" : "password"}
|
|
1239
|
+
placeholder="Leave blank for public access"
|
|
1240
|
+
value={apiKey}
|
|
1241
|
+
onChange={(e) => {
|
|
1242
|
+
setApiKey(e.target.value);
|
|
1243
|
+
localStorage.setItem(
|
|
1244
|
+
"rag_server_api_key",
|
|
1245
|
+
e.target.value,
|
|
1246
|
+
);
|
|
1247
|
+
}}
|
|
1248
|
+
className="pr-9"
|
|
1249
|
+
/>
|
|
1250
|
+
<button
|
|
1251
|
+
type="button"
|
|
1252
|
+
onClick={() => setShowApiKey((v) => !v)}
|
|
1253
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
1254
|
+
tabIndex={-1}
|
|
1255
|
+
>
|
|
1256
|
+
{showApiKey ? (
|
|
1257
|
+
<EyeOff className="size-4" />
|
|
1258
|
+
) : (
|
|
1259
|
+
<Eye className="size-4" />
|
|
1260
|
+
)}
|
|
1261
|
+
</button>
|
|
1262
|
+
</div>
|
|
1263
|
+
<TooltipProvider>
|
|
1264
|
+
<Tooltip>
|
|
1265
|
+
<TooltipTrigger
|
|
1266
|
+
render={
|
|
1267
|
+
<Button
|
|
1268
|
+
variant="outline"
|
|
1269
|
+
size="icon"
|
|
1270
|
+
onClick={handleGenerateKey}
|
|
1271
|
+
>
|
|
1272
|
+
<Dices className="size-4" />
|
|
1273
|
+
</Button>
|
|
1274
|
+
}
|
|
1275
|
+
/>
|
|
1276
|
+
<TooltipContent>
|
|
1277
|
+
<p>Generate random key</p>
|
|
1278
|
+
</TooltipContent>
|
|
1279
|
+
</Tooltip>
|
|
1280
|
+
</TooltipProvider>
|
|
1281
|
+
<Button
|
|
1282
|
+
variant="outline"
|
|
1283
|
+
size="icon"
|
|
1284
|
+
onClick={handleCopyKey}
|
|
1285
|
+
disabled={!apiKey}
|
|
1286
|
+
title="Copy Token"
|
|
1287
|
+
>
|
|
1288
|
+
<Copy className="size-4" />
|
|
1289
|
+
</Button>
|
|
1290
|
+
</div>
|
|
1291
|
+
</div>
|
|
1292
|
+
|
|
1293
|
+
{/* ── Divider ── */}
|
|
1294
|
+
{requiredEnv.length > 0 && (
|
|
1295
|
+
<div className="border-t border-border" />
|
|
1296
|
+
)}
|
|
1297
|
+
|
|
1298
|
+
{/* ── Dynamic Env Vars from .env.example ── */}
|
|
1299
|
+
{requiredEnv.map((env) => (
|
|
1300
|
+
<div key={env.key} className="grid gap-2">
|
|
1301
|
+
<Label htmlFor={`env-${env.key}`}>
|
|
1302
|
+
{env.key}
|
|
1303
|
+
{!env.required && (
|
|
1304
|
+
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
|
1305
|
+
(Optional)
|
|
1306
|
+
</span>
|
|
1307
|
+
)}
|
|
1308
|
+
{env.required && envValues[env.key] && (
|
|
1309
|
+
<span className="inline-flex items-center gap-1 ml-2 text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
|
1310
|
+
<CheckCircle2 className="size-3" />
|
|
1311
|
+
Pre-filled
|
|
1312
|
+
</span>
|
|
1313
|
+
)}
|
|
1314
|
+
</Label>
|
|
1315
|
+
{env.key.toLowerCase().includes("key") ||
|
|
1316
|
+
env.key.toLowerCase().includes("secret") ? (
|
|
1317
|
+
<div className="relative">
|
|
1318
|
+
<Input
|
|
1319
|
+
id={`env-${env.key}`}
|
|
1320
|
+
type={showEnvVars[env.key] ? "text" : "password"}
|
|
1321
|
+
placeholder={env.help || `Enter ${env.key}`}
|
|
1322
|
+
value={envValues[env.key] || ""}
|
|
1323
|
+
onChange={(e) =>
|
|
1324
|
+
setEnvValues({
|
|
1325
|
+
...envValues,
|
|
1326
|
+
[env.key]: e.target.value,
|
|
1327
|
+
})
|
|
1328
|
+
}
|
|
1329
|
+
className="pr-9"
|
|
1330
|
+
/>
|
|
1331
|
+
<button
|
|
1332
|
+
type="button"
|
|
1333
|
+
onClick={() =>
|
|
1334
|
+
setShowEnvVars((prev) => ({
|
|
1335
|
+
...prev,
|
|
1336
|
+
[env.key]: !prev[env.key],
|
|
1337
|
+
}))
|
|
1338
|
+
}
|
|
1339
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
1340
|
+
tabIndex={-1}
|
|
1341
|
+
>
|
|
1342
|
+
{showEnvVars[env.key] ? (
|
|
1343
|
+
<EyeOff className="size-4" />
|
|
1344
|
+
) : (
|
|
1345
|
+
<Eye className="size-4" />
|
|
1346
|
+
)}
|
|
1347
|
+
</button>
|
|
1348
|
+
</div>
|
|
1349
|
+
) : (
|
|
1350
|
+
<Input
|
|
1351
|
+
id={`env-${env.key}`}
|
|
1352
|
+
type="text"
|
|
1353
|
+
placeholder={env.help || `Enter ${env.key}`}
|
|
1354
|
+
value={envValues[env.key] || ""}
|
|
1355
|
+
onChange={(e) =>
|
|
1356
|
+
setEnvValues({ ...envValues, [env.key]: e.target.value })
|
|
1357
|
+
}
|
|
1358
|
+
/>
|
|
1359
|
+
)}
|
|
1360
|
+
{env.help && (
|
|
1361
|
+
<p className="text-xs text-muted-foreground">{env.help}</p>
|
|
1362
|
+
)}
|
|
1363
|
+
</div>
|
|
1364
|
+
))}
|
|
1365
|
+
|
|
1366
|
+
{requiredEnv.length === 0 && (
|
|
1367
|
+
<p className="text-xs text-muted-foreground italic">
|
|
1368
|
+
No additional environment variables required for this server
|
|
1369
|
+
configuration.
|
|
1370
|
+
</p>
|
|
1371
|
+
)}
|
|
1372
|
+
</div>
|
|
1373
|
+
|
|
1374
|
+
<SheetFooter className="sticky bottom-0 z-10 -mx-3 mt-auto flex-row justify-between border-t bg-background px-4 py-4 sm:flex-row sm:justify-between sm:space-x-0">
|
|
1375
|
+
<Button
|
|
1376
|
+
variant="outline"
|
|
1377
|
+
className={"bg-white px-5"}
|
|
1378
|
+
onClick={() => setEnvModalOpen(false)}
|
|
1379
|
+
>
|
|
1380
|
+
Cancel
|
|
1381
|
+
</Button>
|
|
1382
|
+
<Button className={"px-7"} onClick={handleSaveEnv}>
|
|
1383
|
+
Save
|
|
1384
|
+
</Button>
|
|
1385
|
+
</SheetFooter>
|
|
1386
|
+
</SheetContent>
|
|
1387
|
+
</Sheet>
|
|
1388
|
+
</div>
|
|
1389
|
+
);
|
|
1390
|
+
}
|