main
js 482 lines 14.1 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import * as Sleep from "/js/sleep.js";
3
4 // define the model object holding data and functions
5 const model = {
6 isLoading: false,
7 tunnelLink: "",
8 linkGenerated: false,
9 loadingText: "",
10 qrCodeInstance: null,
11 provider: "cloudflared",
12 loginProvider: "",
13 microsoftLoginCode: "",
14 microsoftLoginUrl: "",
15 codeCopied: false,
16 copyState: "",
17 notificationPollInterval: null,
18 hasError: false,
19
20 init() {
21 this.checkTunnelStatus();
22 },
23
24 cleanup() {
25 this.stopNotificationPolling();
26 },
27
28 get copyLinkIcon() {
29 if (this.copyState === "success") return "check";
30 if (this.copyState === "error") return "close";
31 return "content_copy";
32 },
33
34 get copyLinkLabel() {
35 if (this.copyState === "success") return "Copied";
36 if (this.copyState === "error") return "Copy failed";
37 return "Copy link";
38 },
39
40 get loginActionVisible() {
41 return Boolean(this.microsoftLoginUrl || this.microsoftLoginCode);
42 },
43
44 get loginActionTitle() {
45 return this.loginProvider === "tailscale" ? "Tailscale sign-in" : "Microsoft sign-in";
46 },
47
48 get loginActionCopy() {
49 if (this.loginProvider === "tailscale") {
50 return "Open the Tailscale link to approve this container or enable Funnel. Agent Zero will continue when Tailscale reports the public URL.";
51 }
52 return "Approve the tunnel request, then Agent Zero will finish enabling Remote Control.";
53 },
54
55 clearMicrosoftLogin() {
56 this.loginProvider = "";
57 this.microsoftLoginCode = "";
58 this.microsoftLoginUrl = "";
59 this.codeCopied = false;
60 },
61
62 copyLoginCode() {
63 if (!this.microsoftLoginCode) return;
64 navigator.clipboard.writeText(this.microsoftLoginCode).then(() => {
65 this.codeCopied = true;
66 window.toastFrontendInfo("Login code copied to clipboard!", "Clipboard");
67 // Reset after 3 seconds
68 setTimeout(() => {
69 this.codeCopied = false;
70 }, 3000);
71 }).catch((err) => {
72 console.error("Failed to copy code: ", err);
73 window.toastFrontendError("Failed to copy login code", "Clipboard Error");
74 });
75 },
76
77 processNotifications(notifications) {
78 if (!notifications || !Array.isArray(notifications)) return;
79
80 for (const n of notifications) {
81 switch (n.event) {
82 case "downloading":
83 this.loadingText = n.message;
84 break;
85 case "download_progress":
86 if (n.data && n.data.percent !== undefined) {
87 this.loadingText = `Downloading: ${n.data.percent.toFixed(1)}%`;
88 } else {
89 this.loadingText = n.message;
90 }
91 break;
92 case "download_complete":
93 this.loadingText = n.message;
94 break;
95 case "creating_tunnel":
96 this.clearMicrosoftLogin();
97 this.loadingText = n.message;
98 break;
99 case "info":
100 // Sign-in providers can provide a device code, a login URL, or both.
101 if (n.data && n.data.url) {
102 this.loginProvider = n.data.provider || (n.data.code ? "microsoft" : "tailscale");
103 this.microsoftLoginCode = n.data.code || "";
104 this.microsoftLoginUrl = n.data.url || "";
105 this.loadingText = this.loginProvider === "tailscale"
106 ? "Waiting for Tailscale approval..."
107 : "Waiting for Microsoft login...";
108 } else {
109 this.loadingText = n.message;
110 }
111 break;
112 case "error":
113 this.hasError = true;
114 window.toastFrontendError(n.message, "Remote Control");
115 this.stopNotificationPolling();
116 break;
117 case "tunnel_url":
118 if (n.data && n.data.url) {
119 this.tunnelLink = n.data.url;
120 this.linkGenerated = true;
121 Sleep.Skip().then(() => this.generateQRCode());
122 }
123 break;
124 case "tunnel_stopped":
125 this.loadingText = n.message;
126 break;
127 }
128 }
129 },
130
131 startNotificationPolling() {
132 this.stopNotificationPolling();
133 this.hasError = false;
134 this.notificationPollInterval = setInterval(async () => {
135 try {
136 const response = await fetchApi("/tunnel_proxy", {
137 method: "POST",
138 headers: { "Content-Type": "application/json" },
139 body: JSON.stringify({ action: "notifications" }),
140 });
141 const data = await response.json();
142 if (data.notifications) {
143 this.processNotifications(data.notifications);
144 }
145 // Check if tunnel is ready
146 if (data.tunnel_url && data.is_running) {
147 this.tunnelLink = data.tunnel_url;
148 this.linkGenerated = true;
149 Sleep.Skip().then(() => this.generateQRCode());
150 this.stopNotificationPolling();
151 }
152 } catch (error) {
153 console.error("Error polling notifications:", error);
154 }
155 }, 500);
156 },
157
158 stopNotificationPolling() {
159 if (this.notificationPollInterval) {
160 clearInterval(this.notificationPollInterval);
161 this.notificationPollInterval = null;
162 }
163 },
164
165 generateQRCode() {
166 if (!this.tunnelLink) return;
167
168 const qrContainer = document.getElementById("qrcode-tunnel");
169 if (!qrContainer) return;
170
171 // Clear any existing QR code
172 qrContainer.innerHTML = "";
173
174 try {
175 // Generate new QR code
176 this.qrCodeInstance = new QRCode(qrContainer, {
177 text: this.tunnelLink,
178 width: 128,
179 height: 128,
180 colorDark: "#000000",
181 colorLight: "#ffffff",
182 correctLevel: QRCode.CorrectLevel.M,
183 });
184 } catch (error) {
185 console.error("Error generating QR code:", error);
186 qrContainer.innerHTML =
187 '<div class="qr-error">QR code generation failed</div>';
188 }
189 },
190
191 async checkTunnelStatus() {
192 try {
193 const response = await fetchApi("/tunnel_proxy", {
194 method: "POST",
195 headers: {
196 "Content-Type": "application/json",
197 },
198 body: JSON.stringify({ action: "get" }),
199 });
200
201 const data = await response.json();
202
203 if (data.success && data.tunnel_url) {
204 // Update the stored URL if it's different from what we have
205 if (this.tunnelLink !== data.tunnel_url) {
206 this.tunnelLink = data.tunnel_url;
207 localStorage.setItem("agent_zero_tunnel_url", data.tunnel_url);
208 }
209 this.linkGenerated = true;
210 // Generate QR code for the tunnel URL
211 Sleep.Skip().then(() => this.generateQRCode());
212 } else {
213 // Check if we have a stored tunnel URL
214 const storedTunnelUrl = localStorage.getItem("agent_zero_tunnel_url");
215
216 if (storedTunnelUrl) {
217 // Use the stored URL but verify it's still valid
218 const verifyResponse = await fetchApi("/tunnel_proxy", {
219 method: "POST",
220 headers: {
221 "Content-Type": "application/json",
222 },
223 body: JSON.stringify({ action: "verify", url: storedTunnelUrl }),
224 });
225
226 const verifyData = await verifyResponse.json();
227
228 if (verifyData.success && verifyData.is_valid) {
229 this.tunnelLink = storedTunnelUrl;
230 this.linkGenerated = true;
231 // Generate QR code for the tunnel URL
232 Sleep.Skip().then(() => this.generateQRCode());
233 } else {
234 // Clear stale URL
235 localStorage.removeItem("agent_zero_tunnel_url");
236 this.tunnelLink = "";
237 this.linkGenerated = false;
238 }
239 } else {
240 // No stored URL, show the generate button
241 this.tunnelLink = "";
242 this.linkGenerated = false;
243 }
244 }
245 } catch (error) {
246 console.error("Error checking tunnel status:", error);
247 this.tunnelLink = "";
248 this.linkGenerated = false;
249 }
250 },
251
252 async refreshLink() {
253 // Call generate but with a confirmation first
254 if (
255 confirm(
256 "Create new Remote Control access? The current URL will stop working."
257 )
258 ) {
259
260 this.isLoading = true;
261 this.hasError = false;
262 this.clearMicrosoftLogin();
263 this.loadingText = "Refreshing tunnel...";
264
265 try {
266 // First stop any existing tunnel
267 const stopResponse = await fetchApi("/tunnel_proxy", {
268 method: "POST",
269 headers: {
270 "Content-Type": "application/json",
271 },
272 body: JSON.stringify({ action: "stop" }),
273 });
274
275 // Check if stopping was successful
276 const stopData = await stopResponse.json();
277 if (!stopData.success) {
278 console.warn("Warning: Couldn't stop existing tunnel cleanly");
279 // Continue anyway since we want to create a new one
280 }
281
282 // Then generate a new one
283 await this.generateLink();
284 } catch (error) {
285 console.error("Error refreshing tunnel:", error);
286 window.toastFrontendError("Error refreshing Remote Control", "Remote Control");
287 this.isLoading = false;
288 this.loadingText = "";
289 }
290 }
291 },
292
293 async generateLink() {
294 // First check if authentication is enabled
295 try {
296 const authCheckResponse = await fetchApi("/settings_get");
297 const authData = await authCheckResponse.json();
298
299 // Find the auth_login and auth_password in the settings
300 let hasAuth = false;
301
302 if (authData && authData.settings) {
303 const { auth_login, auth_password } = authData.settings;
304 hasAuth = Boolean(auth_login && auth_password);
305 }
306
307 // If no authentication is set, warn the user
308 if (!hasAuth) {
309 const proceed = confirm(
310 "Remote Control works best with sign-in enabled.\n\n" +
311 "Without a login, anyone with the URL can reach this Agent Zero instance.\n\n" +
312 "Turn on authentication in Settings before sharing this link. Continue anyway?"
313 );
314
315 if (!proceed) {
316 return; // User cancelled
317 }
318 }
319 } catch (error) {
320 console.error("Error checking authentication status:", error);
321 // Continue anyway if we can't check auth status
322 }
323
324 this.isLoading = true;
325 this.hasError = false;
326 this.clearMicrosoftLogin();
327 this.loadingText = "Starting tunnel...";
328
329 // Start polling for notifications
330 this.startNotificationPolling();
331
332 try {
333 // Call the backend API to create a tunnel
334 const response = await fetchApi("/tunnel_proxy", {
335 method: "POST",
336 headers: {
337 "Content-Type": "application/json",
338 },
339 body: JSON.stringify({
340 action: "create",
341 provider: this.provider,
342 }),
343 });
344
345 const data = await response.json();
346
347 // Process any notifications from response
348 if (data.notifications) {
349 this.processNotifications(data.notifications);
350 }
351
352 // Check for error
353 if (!data.success && data.message) {
354 this.hasError = true;
355 window.toastFrontendError(data.message, "Remote Control");
356 console.error("Tunnel creation failed:", data);
357 this.stopNotificationPolling();
358 return;
359 }
360
361 if (data.success && data.tunnel_url) {
362 // Store the tunnel URL in localStorage for persistence
363 localStorage.setItem("agent_zero_tunnel_url", data.tunnel_url);
364
365 this.tunnelLink = data.tunnel_url;
366 this.linkGenerated = true;
367 this.stopNotificationPolling();
368
369 // Generate QR code for the tunnel URL
370 Sleep.Skip().then(() => this.generateQRCode());
371
372 // Show success message to confirm creation
373 window.toastFrontendInfo(
374 "Remote Control is ready",
375 "Remote Control"
376 );
377 }
378 } catch (error) {
379 window.toastFrontendError("Error creating Remote Control", "Remote Control");
380 console.error("Error creating tunnel:", error);
381 } finally {
382 this.isLoading = false;
383 this.loadingText = "";
384 this.stopNotificationPolling();
385 this.clearMicrosoftLogin();
386
387 }
388 },
389
390 async stopTunnel() {
391 if (
392 confirm(
393 "Stop Remote Control? The current URL will no longer be accessible."
394 )
395 ) {
396 this.isLoading = true;
397 this.loadingText = "Stopping tunnel...";
398
399 try {
400 // Call the backend to stop the tunnel
401 const response = await fetchApi("/tunnel_proxy", {
402 method: "POST",
403 headers: {
404 "Content-Type": "application/json",
405 },
406 body: JSON.stringify({ action: "stop" }),
407 });
408
409 const data = await response.json();
410
411 if (data.success) {
412 // Clear the stored URL
413 localStorage.removeItem("agent_zero_tunnel_url");
414
415 // Clear QR code
416 const qrContainer = document.getElementById("qrcode-tunnel");
417 if (qrContainer) {
418 qrContainer.innerHTML = "";
419 }
420 this.qrCodeInstance = null;
421
422 // Update UI state
423 this.tunnelLink = "";
424 this.linkGenerated = false;
425
426 window.toastFrontendInfo(
427 "Remote Control stopped",
428 "Remote Control"
429 );
430 } else {
431 window.toastFrontendError("Failed to stop Remote Control", "Remote Control");
432 }
433 } catch (error) {
434 window.toastFrontendError("Error stopping Remote Control", "Remote Control");
435 console.error("Error stopping tunnel:", error);
436 } finally {
437 this.isLoading = false;
438 this.loadingText = "";
439 }
440 }
441 },
442
443 copyToClipboard() {
444 if (!this.tunnelLink) return;
445
446 navigator.clipboard
447 .writeText(this.tunnelLink)
448 .then(() => {
449 this.copyState = "success";
450
451 // Show toast notification
452 window.toastFrontendInfo(
453 "Remote Control URL copied",
454 "Clipboard"
455 );
456
457 // Reset button after 2 seconds
458 setTimeout(() => {
459 this.copyState = "";
460 }, 2000);
461 })
462 .catch((err) => {
463 console.error("Failed to copy URL: ", err);
464 this.copyState = "error";
465 window.toastFrontendError(
466 "Failed to copy Remote Control URL",
467 "Clipboard Error"
468 );
469
470 // Reset button after 2 seconds
471 setTimeout(() => {
472 this.copyState = "";
473 }, 2000);
474 });
475 },
476 };
477
478 // convert it to alpine store
479 const store = createStore("tunnelStore", model);
480
481 // export for use in other files
482 export { store };