tunnel component refactor

frdel committed Aug 15, 2025 at 11:22 UTC eee6796299c42b0fffb69ab71a54d2f7e8cc1f91
8 files changed +807 -726
python/api/settings_get.py
+4
@@ -6,3 +6,7 @@ class GetSettings(ApiHandler):
6 async def process(self, input: dict, request: Request) -> dict | Response:
7 set = settings.convert_out(settings.get_settings())
8 return {"settings": set}
9 +
10 + @classmethod
11 + def get_methods(cls) -> list[str]:
12 + return ["GET", "POST"]
webui/components/notifications/notification-toast-stack.html
+1 -1
@@ -56,7 +56,7 @@
56 position: absolute;
57 bottom: 5px; /* Spacing from the bottom of the zero-height container */
58 padding-right: 5px;
59 - z-index: 1500;
59 + z-index: 15000;
60 display: flex;
61 flex-direction: column-reverse; /* Stack toasts upwards */
62 gap: 8px;
webui/components/settings/tunnel/tunnel-section.html new
+371
@@ -0,0 +1,371 @@
1 +<html>
2 +
3 +<head>
4 + <title></title>
5 +
6 + <!-- Import the alpine store -->
7 + <script type="module">
8 + import { store } from "/components/settings/tunnel/tunnel-store.js";
9 + </script>
10 +</head>
11 +
12 +<body>
13 +
14 + <!-- This construct of x-data + x-if is used to ensure the component is only rendered when the store is available -->
15 + <div x-data>
16 + <template x-if="$store.tunnelStore">
17 +
18 + <!-- Keep in mind that <template> can have only one root element inside -->
19 + <div id="tunnel-settings-section">
20 + <div class="section-title">Flare Tunnel</div>
21 + <div class="section-description">Create a secure public URL to access your Agent Zero instance anytime,
22 + anywhere.</div>
23 + <!-- Tunnel content UI -->
24 + <div class="tunnel-container">
25 + <div class="field">
26 + <div class="field-label">
27 + <div class="field-title">Tunnel provider</div>
28 + <div class="field-description">Select provider for public tunnel</div>
29 + </div>
30 + <div class="field-control">
31 + <select id="tunnel-provider" x-model="$store.tunnelStore.provider"
32 + :disabled="$store.tunnelStore.isLoading">
33 + <option value="cloudflared">Cloudflare</option>
34 + <option value="serveo">Serveo</option>
35 + </select>
36 + </div>
37 + </div>
38 + <!-- Loading spinner for tunnel operations -->
39 + <div class="loading-spinner" x-show="$store.tunnelStore.isLoading">
40 + <span class="icon material-symbols-outlined spin">progress_activity</span>
41 + <span x-text="$store.tunnelStore.loadingText || 'Processing tunnel request...'"></span>
42 + </div>
43 +
44 + <!-- Tunnel content when not loading -->
45 + <div x-show="!$store.tunnelStore.isLoading">
46 + <!-- Tunnel link display when generated -->
47 + <div class="tunnel-link-container" x-show="$store.tunnelStore.linkGenerated">
48 + <div class="tunnel-link-field">
49 + <input type="text" class="tunnel-link-input" :value="$store.tunnelStore.tunnelLink"
50 + readonly>
51 + <div class="buttons-row">
52 + <button class="copy-link-button" @click="$store.tunnelStore.copyToClipboard()"
53 + title="Copy to clipboard">
54 + <span class="icon material-symbols-outlined">content_copy</span> Copy
55 + </button>
56 + <button class="refresh-link-button" @click="$store.tunnelStore.refreshLink()"
57 + title="Generate new URL">
58 + <span class="icon material-symbols-outlined">refresh</span> Refresh
59 + </button>
60 + </div>
61 + </div>
62 + <div class="tunnel-qr-container">
63 + <div class="tunnel-qr-code" id="qrcode-tunnel">
64 + <!-- QR code will be generated here -->
65 + </div>
66 + <div class="tunnel-qr-label">Scan with mobile device</div>
67 + </div>
68 + <div class="tunnel-link-info">
69 + Share this URL to allow others to access your Agent Zero instance.
70 + </div>
71 + <div class="tunnel-link-persistence">
72 + This URL will persist until you stop the tunnel or restart the Docker container.
73 + </div>
74 + <div class="stop-tunnel-container">
75 + <button class="btn btn-danger" @click="$store.tunnelStore.stopTunnel()">
76 + <span class="icon material-symbols-outlined">stop_circle</span> Stop Tunnel
77 + </button>
78 + </div>
79 + </div>
80 + <!-- Generate tunnel button when no link exists -->
81 + <div class="tunnel-actions" x-show="!$store.tunnelStore.linkGenerated">
82 + <button class="btn btn-ok" @click="$store.tunnelStore.generateLink()">
83 + <span class="icon material-symbols-outlined">play_circle</span> Create Tunnel
84 + </button>
85 + </div>
86 + </div>
87 + </div>
88 + </div>
89 +
90 + </template>
91 + </div>
92 +
93 + <!-- Optional style for the component -->
94 + <style>
95 + /* Tunnel Modal Styles */
96 + .tunnel-container {
97 + padding: 1rem;
98 + width: 100%;
99 + }
100 +
101 + .tunnel-description {
102 + margin-bottom: 1.5rem;
103 + text-align: center;
104 + }
105 +
106 + .tunnel-actions {
107 + display: flex;
108 + justify-content: center;
109 + margin-top: 2rem;
110 + margin-bottom: 2rem;
111 + }
112 +
113 + .tunnel-link-container {
114 + margin-top: 1rem;
115 + display: flex;
116 + flex-direction: column;
117 + gap: 1rem;
118 + }
119 +
120 + .tunnel-link-field {
121 + display: flex;
122 + align-items: center;
123 + margin-bottom: 0.5rem;
124 + background-color: var(--bg-color-secondary);
125 + border: 1px solid var(--border-color);
126 + border-radius: 4px;
127 + }
128 +
129 + .tunnel-link-input {
130 + flex: 1;
131 + padding: 0.75rem;
132 + background-color: transparent;
133 + border: none;
134 + color: var(--text-color);
135 + font-size: 0.9rem;
136 + outline: none;
137 + }
138 +
139 + .copy-link-button {
140 + padding: 0.5rem 0.75rem;
141 + background: none;
142 + border: none;
143 + border-left: 1px solid var(--border-color);
144 + color: var(--text-color);
145 + cursor: pointer;
146 + transition: background-color 0.2s;
147 + }
148 +
149 + .copy-link-button:hover {
150 + background-color: var(--bg-color-tertiary);
151 + }
152 +
153 + .copy-link-button i,
154 + .refresh-link-button i,
155 + .btn i {
156 + margin-right: 6px;
157 + }
158 +
159 + .tunnel-link-info {
160 + margin-top: 1rem;
161 + font-size: 1rem;
162 + color: var(--text-color);
163 + text-align: center;
164 + line-height: 1.5;
165 + }
166 +
167 + .loading-spinner {
168 + display: flex;
169 + justify-content: center;
170 + align-items: center;
171 + margin-top: 2rem;
172 + margin-bottom: 2rem;
173 + min-height: 38px;
174 + font-style: italic;
175 + color: var(--text-color-secondary);
176 + }
177 +
178 + .loading-spinner i {
179 + font-size: 1.5rem;
180 + margin-right: 10px;
181 + color: var(--accent-color);
182 + }
183 +
184 + .refresh-link-button {
185 + padding: 0.5rem 0.75rem;
186 + background: none;
187 + border: none;
188 + border-left: 1px solid var(--border-color);
189 + color: var(--text-color);
190 + cursor: pointer;
191 + transition: background-color 0.2s;
192 + }
193 +
194 + .refresh-link-button:hover {
195 + background-color: var(--bg-color-tertiary);
196 + color: var(--accent-color);
197 + }
198 +
199 + .tunnel-link-persistence {
200 + margin-top: 0.75rem;
201 + font-size: 0.95rem;
202 + color: rgba(255, 255, 255, 0.7);
203 + text-align: center;
204 + font-style: italic;
205 + }
206 +
207 + .btn-danger {
208 + background-color: #dc3545;
209 + color: white;
210 + border: none;
211 + }
212 +
213 + .btn-danger:hover {
214 + background-color: #bd2130;
215 + }
216 +
217 + .stop-tunnel-container {
218 + margin-top: 20px;
219 + display: flex;
220 + justify-content: center;
221 + }
222 +
223 + /* Section title icon styling */
224 + .section-title i {
225 + margin-right: 8px;
226 + }
227 +
228 + /* Copy button states */
229 + .copy-success {
230 + background-color: rgba(40, 167, 69, 0.15) !important;
231 + color: #28a745 !important;
232 + border-left: 1px solid rgba(40, 167, 69, 0.5) !important;
233 + transition: all 0.3s ease-in-out;
234 + }
235 +
236 + .copy-error {
237 + background-color: rgba(220, 53, 69, 0.15) !important;
238 + color: #dc3545 !important;
239 + border-left: 1px solid rgba(220, 53, 69, 0.5) !important;
240 + transition: all 0.3s ease-in-out;
241 + }
242 +
243 + /* Animation for copy button */
244 + @keyframes pulse {
245 + 0% {
246 + transform: scale(1);
247 + }
248 +
249 + 50% {
250 + transform: scale(1.05);
251 + }
252 +
253 + 100% {
254 + transform: scale(1);
255 + }
256 + }
257 +
258 + .copy-success,
259 + .copy-error {
260 + animation: pulse 0.5s;
261 + }
262 +
263 + /* Refresh button state */
264 + .refreshing {
265 + opacity: 0.7;
266 + pointer-events: none;
267 + background-color: rgba(108, 117, 125, 0.15) !important;
268 + border-left: 1px solid rgba(108, 117, 125, 0.5) !important;
269 + }
270 +
271 + /* Create and Stop button states */
272 + .creating,
273 + .stopping {
274 + opacity: 0.8;
275 + pointer-events: none;
276 + cursor: not-allowed;
277 + }
278 +
279 + .creating {
280 + background-color: rgba(0, 123, 255, 0.7) !important;
281 + }
282 +
283 + .stopping {
284 + background-color: rgba(220, 53, 69, 0.7) !important;
285 + }
286 +
287 + /* QR Code Styles */
288 + .tunnel-qr-container {
289 + display: flex;
290 + align-items: center;
291 + justify-content: flex-start;
292 + gap: 1rem;
293 + margin: 0.5rem 0;
294 + padding: 1rem;
295 + background-color: var(--bg-color-secondary);
296 + border: 1px solid var(--border-color);
297 + border-radius: 8px;
298 + }
299 +
300 + .tunnel-qr-code {
301 + flex-shrink: 0;
302 + display: flex;
303 + align-items: center;
304 + justify-content: center;
305 + min-width: 128px;
306 + min-height: 128px;
307 + background-color: white;
308 + border-radius: 8px;
309 + padding: 8px;
310 + }
311 +
312 + .tunnel-qr-code canvas,
313 + .tunnel-qr-code img {
314 + border-radius: 4px;
315 + max-width: 100%;
316 + max-height: 100%;
317 + }
318 +
319 + .tunnel-qr-label {
320 + font-size: 0.9rem;
321 + color: var(--text-color-secondary);
322 + text-align: center;
323 + line-height: 1.4;
324 + flex: 1;
325 + }
326 +
327 + .qr-error {
328 + color: var(--error-color, #dc3545);
329 + font-size: 0.8rem;
330 + text-align: center;
331 + padding: 1rem;
332 + background-color: rgba(220, 53, 69, 0.1);
333 + border-radius: 4px;
334 + border: 1px solid rgba(220, 53, 69, 0.2);
335 + }
336 +
337 + /* Responsive design for QR code container */
338 + @media (max-width: 640px) {
339 + .tunnel-qr-container {
340 + flex-direction: column;
341 + text-align: center;
342 + gap: 0.75rem;
343 + padding: 0.75rem;
344 + }
345 +
346 + .tunnel-qr-code {
347 + min-width: 100px;
348 + min-height: 100px;
349 + align-self: center;
350 + }
351 +
352 + .tunnel-qr-label {
353 + text-align: center;
354 + }
355 + }
356 +
357 + /* Light mode adjustments */
358 + .light-mode .tunnel-qr-code {
359 + background-color: #ffffff;
360 + border: 1px solid #e0e0e0;
361 + }
362 +
363 + .light-mode .qr-error {
364 + background-color: rgba(220, 53, 69, 0.05);
365 + color: #dc3545;
366 + }
367 + </style>
368 +
369 +</body>
370 +
371 +</html>
\ No newline at end of file
webui/components/settings/tunnel/tunnel-store.js new
+430
@@ -0,0 +1,430 @@
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 +
13 + init() {
14 + this.checkTunnelStatus();
15 + },
16 +
17 + generateQRCode() {
18 + if (!this.tunnelLink) return;
19 +
20 + const qrContainer = document.getElementById("qrcode-tunnel");
21 + if (!qrContainer) return;
22 +
23 + // Clear any existing QR code
24 + qrContainer.innerHTML = "";
25 +
26 + try {
27 + // Generate new QR code
28 + this.qrCodeInstance = new QRCode(qrContainer, {
29 + text: this.tunnelLink,
30 + width: 128,
31 + height: 128,
32 + colorDark: "#000000",
33 + colorLight: "#ffffff",
34 + correctLevel: QRCode.CorrectLevel.M,
35 + });
36 + } catch (error) {
37 + console.error("Error generating QR code:", error);
38 + qrContainer.innerHTML =
39 + '<div class="qr-error">QR code generation failed</div>';
40 + }
41 + },
42 +
43 + async checkTunnelStatus() {
44 + try {
45 + const response = await fetchApi("/tunnel_proxy", {
46 + method: "POST",
47 + headers: {
48 + "Content-Type": "application/json",
49 + },
50 + body: JSON.stringify({ action: "get" }),
51 + });
52 +
53 + const data = await response.json();
54 +
55 + if (data.success && data.tunnel_url) {
56 + // Update the stored URL if it's different from what we have
57 + if (this.tunnelLink !== data.tunnel_url) {
58 + this.tunnelLink = data.tunnel_url;
59 + localStorage.setItem("agent_zero_tunnel_url", data.tunnel_url);
60 + }
61 + this.linkGenerated = true;
62 + // Generate QR code for the tunnel URL
63 + Sleep.Skip().then(() => this.generateQRCode());
64 + } else {
65 + // Check if we have a stored tunnel URL
66 + const storedTunnelUrl = localStorage.getItem("agent_zero_tunnel_url");
67 +
68 + if (storedTunnelUrl) {
69 + // Use the stored URL but verify it's still valid
70 + const verifyResponse = await fetchApi("/tunnel_proxy", {
71 + method: "POST",
72 + headers: {
73 + "Content-Type": "application/json",
74 + },
75 + body: JSON.stringify({ action: "verify", url: storedTunnelUrl }),
76 + });
77 +
78 + const verifyData = await verifyResponse.json();
79 +
80 + if (verifyData.success && verifyData.is_valid) {
81 + this.tunnelLink = storedTunnelUrl;
82 + this.linkGenerated = true;
83 + // Generate QR code for the tunnel URL
84 + Sleep.Skip().then(() => this.generateQRCode());
85 + } else {
86 + // Clear stale URL
87 + localStorage.removeItem("agent_zero_tunnel_url");
88 + this.tunnelLink = "";
89 + this.linkGenerated = false;
90 + }
91 + } else {
92 + // No stored URL, show the generate button
93 + this.tunnelLink = "";
94 + this.linkGenerated = false;
95 + }
96 + }
97 + } catch (error) {
98 + console.error("Error checking tunnel status:", error);
99 + this.tunnelLink = "";
100 + this.linkGenerated = false;
101 + }
102 + },
103 +
104 + async refreshLink() {
105 + // Call generate but with a confirmation first
106 + if (
107 + confirm(
108 + "Are you sure you want to generate a new tunnel URL? The old URL will no longer work."
109 + )
110 + ) {
111 +
112 + this.isLoading = true;
113 + this.loadingText = "Refreshing tunnel...";
114 +
115 + // Change refresh button appearance
116 + const refreshButton = document.querySelector("#tunnel-settings-section .refresh-link-button");
117 + const originalContent = refreshButton.innerHTML;
118 + refreshButton.innerHTML =
119 + '<span class="icon material-symbols-outlined spin">progress_activity</span> Refreshing...';
120 + refreshButton.disabled = true;
121 + refreshButton.classList.add("refreshing");
122 +
123 + try {
124 + // First stop any existing tunnel
125 + const stopResponse = await fetchApi("/tunnel_proxy", {
126 + method: "POST",
127 + headers: {
128 + "Content-Type": "application/json",
129 + },
130 + body: JSON.stringify({ action: "stop" }),
131 + });
132 +
133 + // Check if stopping was successful
134 + const stopData = await stopResponse.json();
135 + if (!stopData.success) {
136 + console.warn("Warning: Couldn't stop existing tunnel cleanly");
137 + // Continue anyway since we want to create a new one
138 + }
139 +
140 + // Then generate a new one
141 + await this.generateLink();
142 + } catch (error) {
143 + console.error("Error refreshing tunnel:", error);
144 + window.toastFrontendError("Error refreshing tunnel", "Tunnel Error");
145 + this.isLoading = false;
146 + this.loadingText = "";
147 + } finally {
148 + // Reset refresh button
149 + refreshButton.innerHTML = originalContent;
150 + refreshButton.disabled = false;
151 + refreshButton.classList.remove("refreshing");
152 + }
153 + }
154 + },
155 +
156 + async generateLink() {
157 + // First check if authentication is enabled
158 + try {
159 + const authCheckResponse = await fetchApi("/settings_get");
160 + const authData = await authCheckResponse.json();
161 +
162 + // Find the auth_login and auth_password in the settings
163 + let hasAuth = false;
164 +
165 + if (authData && authData.settings && authData.settings.sections) {
166 + for (const section of authData.settings.sections) {
167 + if (section.fields) {
168 + const authLoginField = section.fields.find(
169 + (field) => field.id === "auth_login"
170 + );
171 + const authPasswordField = section.fields.find(
172 + (field) => field.id === "auth_password"
173 + );
174 +
175 + if (
176 + authLoginField &&
177 + authPasswordField &&
178 + authLoginField.value &&
179 + authPasswordField.value
180 + ) {
181 + hasAuth = true;
182 + break;
183 + }
184 + }
185 + }
186 + }
187 +
188 + // If no authentication is set, warn the user
189 + if (!hasAuth) {
190 + const proceed = confirm(
191 + "WARNING: No authentication is configured for your Agent Zero instance.\n\n" +
192 + "Creating a public tunnel without authentication means anyone with the URL " +
193 + "can access your Agent Zero instance.\n\n" +
194 + "It is recommended to set up authentication in the Settings > Authentication section " +
195 + "before creating a public tunnel.\n\n" +
196 + "Do you want to proceed anyway?"
197 + );
198 +
199 + if (!proceed) {
200 + return; // User cancelled
201 + }
202 + }
203 + } catch (error) {
204 + console.error("Error checking authentication status:", error);
205 + // Continue anyway if we can't check auth status
206 + }
207 +
208 + this.isLoading = true;
209 + this.loadingText = "Creating tunnel...";
210 +
211 + // Change create button appearance
212 + const createButton = document.querySelector("#tunnel-settings-section .tunnel-actions .btn-ok");
213 + if (createButton) {
214 + createButton.innerHTML =
215 + '<span class="icon material-symbols-outlined spin">progress_activity</span> Creating...';
216 + createButton.disabled = true;
217 + createButton.classList.add("creating");
218 + }
219 +
220 + try {
221 + // Call the backend API to create a tunnel
222 + const response = await fetchApi("/tunnel_proxy", {
223 + method: "POST",
224 + headers: {
225 + "Content-Type": "application/json",
226 + },
227 + body: JSON.stringify({
228 + action: "create",
229 + provider: this.provider,
230 + // port: window.location.port || (window.location.protocol === 'https:' ? 443 : 80)
231 + }),
232 + });
233 +
234 + const data = await response.json();
235 +
236 + if (data.success && data.tunnel_url) {
237 + // Store the tunnel URL in localStorage for persistence
238 + localStorage.setItem("agent_zero_tunnel_url", data.tunnel_url);
239 +
240 + this.tunnelLink = data.tunnel_url;
241 + this.linkGenerated = true;
242 +
243 + // Generate QR code for the tunnel URL
244 + Sleep.Skip().then(() => this.generateQRCode());
245 +
246 + // Show success message to confirm creation
247 + window.toastFrontendInfo(
248 + "Tunnel created successfully",
249 + "Tunnel Status"
250 + );
251 + } else {
252 + // The tunnel might still be starting up, check again after a delay
253 + this.loadingText = "Tunnel creation taking longer than expected...";
254 +
255 + // Wait for 5 seconds and check if the tunnel is running
256 + await new Promise((resolve) => setTimeout(resolve, 5000));
257 +
258 + // Check if tunnel is running now
259 + try {
260 + const statusResponse = await fetchApi("/tunnel_proxy", {
261 + method: "POST",
262 + headers: {
263 + "Content-Type": "application/json",
264 + },
265 + body: JSON.stringify({ action: "get" }),
266 + });
267 +
268 + const statusData = await statusResponse.json();
269 +
270 + if (statusData.success && statusData.tunnel_url) {
271 + // Tunnel is now running, we can update the UI
272 + localStorage.setItem(
273 + "agent_zero_tunnel_url",
274 + statusData.tunnel_url
275 + );
276 + this.tunnelLink = statusData.tunnel_url;
277 + this.linkGenerated = true;
278 +
279 + // Generate QR code for the tunnel URL
280 + Sleep.Skip().then(() => this.generateQRCode());
281 +
282 + window.toastFrontendInfo(
283 + "Tunnel created successfully",
284 + "Tunnel Status"
285 + );
286 + return;
287 + }
288 + } catch (statusError) {
289 + console.error("Error checking tunnel status:", statusError);
290 + }
291 +
292 + // If we get here, the tunnel really failed to start
293 + const errorMessage =
294 + data.message || "Failed to create tunnel. Please try again.";
295 + window.toastFrontendError(errorMessage, "Tunnel Error");
296 + console.error("Tunnel creation failed:", data);
297 + }
298 + } catch (error) {
299 + window.toastFrontendError("Error creating tunnel", "Tunnel Error");
300 + console.error("Error creating tunnel:", error);
301 + } finally {
302 + this.isLoading = false;
303 + this.loadingText = "";
304 +
305 + // Reset create button if it's still in the DOM
306 + const createButton = document.querySelector("#tunnel-settings-section .tunnel-actions .btn-ok");
307 + if (createButton) {
308 + createButton.innerHTML =
309 + '<span class="icon material-symbols-outlined">play_circle</span> Create Tunnel';
310 + createButton.disabled = false;
311 + createButton.classList.remove("creating");
312 + }
313 + }
314 + },
315 +
316 + async stopTunnel() {
317 + if (
318 + confirm(
319 + "Are you sure you want to stop the tunnel? The URL will no longer be accessible."
320 + )
321 + ) {
322 + this.isLoading = true;
323 + this.loadingText = "Stopping tunnel...";
324 +
325 + try {
326 + // Call the backend to stop the tunnel
327 + const response = await fetchApi("/tunnel_proxy", {
328 + method: "POST",
329 + headers: {
330 + "Content-Type": "application/json",
331 + },
332 + body: JSON.stringify({ action: "stop" }),
333 + });
334 +
335 + const data = await response.json();
336 +
337 + if (data.success) {
338 + // Clear the stored URL
339 + localStorage.removeItem("agent_zero_tunnel_url");
340 +
341 + // Clear QR code
342 + const qrContainer = document.getElementById("qrcode-tunnel");
343 + if (qrContainer) {
344 + qrContainer.innerHTML = "";
345 + }
346 + this.qrCodeInstance = null;
347 +
348 + // Update UI state
349 + this.tunnelLink = "";
350 + this.linkGenerated = false;
351 +
352 + window.toastFrontendInfo(
353 + "Tunnel stopped successfully",
354 + "Tunnel Status"
355 + );
356 + } else {
357 + window.toastFrontendError("Failed to stop tunnel", "Tunnel Error");
358 +
359 + // Reset stop button
360 + stopButton.innerHTML = originalStopContent;
361 + stopButton.disabled = false;
362 + stopButton.classList.remove("stopping");
363 + }
364 + } catch (error) {
365 + window.toastFrontendError("Error stopping tunnel", "Tunnel Error");
366 + console.error("Error stopping tunnel:", error);
367 +
368 + // Reset stop button
369 + stopButton.innerHTML = originalStopContent;
370 + stopButton.disabled = false;
371 + stopButton.classList.remove("stopping");
372 + } finally {
373 + this.isLoading = false;
374 + this.loadingText = "";
375 + }
376 + }
377 + },
378 +
379 + copyToClipboard() {
380 + if (!this.tunnelLink) return;
381 +
382 + const copyButton = document.querySelector("#tunnel-settings-section .copy-link-button");
383 + const originalContent = copyButton.innerHTML;
384 +
385 + navigator.clipboard
386 + .writeText(this.tunnelLink)
387 + .then(() => {
388 + // Update button to show success state
389 + copyButton.innerHTML =
390 + '<span class="icon material-symbols-outlined">check</span> Copied!';
391 + copyButton.classList.add("copy-success");
392 +
393 + // Show toast notification
394 + window.toastFrontendInfo(
395 + "Tunnel URL copied to clipboard!",
396 + "Clipboard"
397 + );
398 +
399 + // Reset button after 2 seconds
400 + setTimeout(() => {
401 + copyButton.innerHTML = originalContent;
402 + copyButton.classList.remove("copy-success");
403 + }, 2000);
404 + })
405 + .catch((err) => {
406 + console.error("Failed to copy URL: ", err);
407 + window.toastFrontendError(
408 + "Failed to copy tunnel URL",
409 + "Clipboard Error"
410 + );
411 +
412 + // Show error state
413 + copyButton.innerHTML =
414 + '<span class="icon material-symbols-outlined">close</span> Failed';
415 + copyButton.classList.add("copy-error");
416 +
417 + // Reset button after 2 seconds
418 + setTimeout(() => {
419 + copyButton.innerHTML = originalContent;
420 + copyButton.classList.remove("copy-error");
421 + }, 2000);
422 + });
423 + },
424 +};
425 +
426 +// convert it to alpine store
427 +const store = createStore("tunnelStore", model);
428 +
429 +// export for use in other files
430 +export { store };
webui/css/tunnel.css deleted
-262
@@ -1,262 +0,0 @@
1 -/* Tunnel Modal Styles */
2 -.tunnel-container {
3 - padding: 1rem;
4 - width: 100%;
5 -}
6 -
7 -.tunnel-description {
8 - margin-bottom: 1.5rem;
9 - text-align: center;
10 -}
11 -
12 -.tunnel-actions {
13 - display: flex;
14 - justify-content: center;
15 - margin-top: 2rem;
16 - margin-bottom: 2rem;
17 -}
18 -
19 -.tunnel-link-container {
20 - margin-top: 1rem;
21 - display: flex;
22 - flex-direction: column;
23 - gap: 1rem;
24 -}
25 -
26 -.tunnel-link-field {
27 - display: flex;
28 - align-items: center;
29 - margin-bottom: 0.5rem;
30 - background-color: var(--bg-color-secondary);
31 - border: 1px solid var(--border-color);
32 - border-radius: 4px;
33 -}
34 -
35 -.tunnel-link-input {
36 - flex: 1;
37 - padding: 0.75rem;
38 - background-color: transparent;
39 - border: none;
40 - color: var(--text-color);
41 - font-size: 0.9rem;
42 - outline: none;
43 -}
44 -
45 -.copy-link-button {
46 - padding: 0.5rem 0.75rem;
47 - background: none;
48 - border: none;
49 - border-left: 1px solid var(--border-color);
50 - color: var(--text-color);
51 - cursor: pointer;
52 - transition: background-color 0.2s;
53 -}
54 -
55 -.copy-link-button:hover {
56 - background-color: var(--bg-color-tertiary);
57 -}
58 -
59 -.copy-link-button i,
60 -.refresh-link-button i,
61 -.btn i {
62 - margin-right: 6px;
63 -}
64 -
65 -.tunnel-link-info {
66 - margin-top: 1rem;
67 - font-size: 1rem;
68 - color: var(--text-color);
69 - text-align: center;
70 - line-height: 1.5;
71 -}
72 -
73 -.loading-spinner {
74 - display: flex;
75 - justify-content: center;
76 - align-items: center;
77 - margin-top: 2rem;
78 - margin-bottom: 2rem;
79 - min-height: 38px;
80 - font-style: italic;
81 - color: var(--text-color-secondary);
82 -}
83 -
84 -.loading-spinner i {
85 - font-size: 1.5rem;
86 - margin-right: 10px;
87 - color: var(--accent-color);
88 -}
89 -
90 -.refresh-link-button {
91 - padding: 0.5rem 0.75rem;
92 - background: none;
93 - border: none;
94 - border-left: 1px solid var(--border-color);
95 - color: var(--text-color);
96 - cursor: pointer;
97 - transition: background-color 0.2s;
98 -}
99 -
100 -.refresh-link-button:hover {
101 - background-color: var(--bg-color-tertiary);
102 - color: var(--accent-color);
103 -}
104 -
105 -.tunnel-link-persistence {
106 - margin-top: 0.75rem;
107 - font-size: 0.95rem;
108 - color: rgba(255, 255, 255, 0.7);
109 - text-align: center;
110 - font-style: italic;
111 -}
112 -
113 -.btn-danger {
114 - background-color: #dc3545;
115 - color: white;
116 - border: none;
117 -}
118 -
119 -.btn-danger:hover {
120 - background-color: #bd2130;
121 -}
122 -
123 -.stop-tunnel-container {
124 - margin-top: 20px;
125 - display: flex;
126 - justify-content: center;
127 -}
128 -
129 -/* Section title icon styling */
130 -.section-title i {
131 - margin-right: 8px;
132 -}
133 -
134 -/* Copy button states */
135 -.copy-success {
136 - background-color: rgba(40, 167, 69, 0.15) !important;
137 - color: #28a745 !important;
138 - border-left: 1px solid rgba(40, 167, 69, 0.5) !important;
139 - transition: all 0.3s ease-in-out;
140 -}
141 -
142 -.copy-error {
143 - background-color: rgba(220, 53, 69, 0.15) !important;
144 - color: #dc3545 !important;
145 - border-left: 1px solid rgba(220, 53, 69, 0.5) !important;
146 - transition: all 0.3s ease-in-out;
147 -}
148 -
149 -/* Animation for copy button */
150 -@keyframes pulse {
151 - 0% { transform: scale(1); }
152 - 50% { transform: scale(1.05); }
153 - 100% { transform: scale(1); }
154 -}
155 -
156 -.copy-success, .copy-error {
157 - animation: pulse 0.5s;
158 -}
159 -
160 -/* Refresh button state */
161 -.refreshing {
162 - opacity: 0.7;
163 - pointer-events: none;
164 - background-color: rgba(108, 117, 125, 0.15) !important;
165 - border-left: 1px solid rgba(108, 117, 125, 0.5) !important;
166 -}
167 -
168 -/* Create and Stop button states */
169 -.creating, .stopping {
170 - opacity: 0.8;
171 - pointer-events: none;
172 - cursor: not-allowed;
173 -}
174 -
175 -.creating {
176 - background-color: rgba(0, 123, 255, 0.7) !important;
177 -}
178 -
179 -.stopping {
180 - background-color: rgba(220, 53, 69, 0.7) !important;
181 -}
182 -
183 -/* QR Code Styles */
184 -.tunnel-qr-container {
185 - display: flex;
186 - align-items: center;
187 - justify-content: flex-start;
188 - gap: 1rem;
189 - margin: 0.5rem 0;
190 - padding: 1rem;
191 - background-color: var(--bg-color-secondary);
192 - border: 1px solid var(--border-color);
193 - border-radius: 8px;
194 -}
195 -
196 -.tunnel-qr-code {
197 - flex-shrink: 0;
198 - display: flex;
199 - align-items: center;
200 - justify-content: center;
201 - min-width: 128px;
202 - min-height: 128px;
203 - background-color: white;
204 - border-radius: 8px;
205 - padding: 8px;
206 -}
207 -
208 -.tunnel-qr-code canvas,
209 -.tunnel-qr-code img {
210 - border-radius: 4px;
211 - max-width: 100%;
212 - max-height: 100%;
213 -}
214 -
215 -.tunnel-qr-label {
216 - font-size: 0.9rem;
217 - color: var(--text-color-secondary);
218 - text-align: center;
219 - line-height: 1.4;
220 - flex: 1;
221 -}
222 -
223 -.qr-error {
224 - color: var(--error-color, #dc3545);
225 - font-size: 0.8rem;
226 - text-align: center;
227 - padding: 1rem;
228 - background-color: rgba(220, 53, 69, 0.1);
229 - border-radius: 4px;
230 - border: 1px solid rgba(220, 53, 69, 0.2);
231 -}
232 -
233 -/* Responsive design for QR code container */
234 -@media (max-width: 640px) {
235 - .tunnel-qr-container {
236 - flex-direction: column;
237 - text-align: center;
238 - gap: 0.75rem;
239 - padding: 0.75rem;
240 - }
241 -
242 - .tunnel-qr-code {
243 - min-width: 100px;
244 - min-height: 100px;
245 - align-self: center;
246 - }
247 -
248 - .tunnel-qr-label {
249 - text-align: center;
250 - }
251 -}
252 -
253 -/* Light mode adjustments */
254 -.light-mode .tunnel-qr-code {
255 - background-color: #ffffff;
256 - border: 1px solid #e0e0e0;
257 -}
258 -
259 -.light-mode .qr-error {
260 - background-color: rgba(220, 53, 69, 0.05);
261 - color: #dc3545;
262 -}
webui/index.html
+1 -64
@@ -17,7 +17,6 @@
17 <link rel="stylesheet" href="css/speech.css">
18 <link rel="stylesheet" href="css/history.css">
19 <link rel="stylesheet" href="css/scheduler-datepicker.css">
20 - <link rel="stylesheet" href="css/tunnel.css">
20 <link rel="stylesheet" href="css/notification.css">
21
22 <!-- Flatpickr for datetime picker -->
@@ -689,69 +688,7 @@
688
689 <!-- Tunnel section content - only visible in External Services tab -->
690 <div id="section-tunnel" class="section" x-show="activeTab === 'external'">
692 - <div class="section-title">Flare Tunnel</div>
693 - <div class="section-description">Create a secure public URL to access your Agent Zero instance anytime, anywhere.</div>
694 - <!-- Tunnel content UI -->
695 - <div class="tunnel-container" x-data="tunnelSettings">
696 - <div class="field">
697 - <div class="field-label">
698 - <div class="field-title">Tunnel provider</div>
699 - <div class="field-description">Select provider for public tunnel</div>
700 - </div>
701 - <div class="field-control">
702 - <select id="tunnel-provider" x-model="provider" :disabled="isLoading">
703 - <option value="cloudflared">Cloudflare</option>
704 - <option value="serveo">Serveo</option>
705 - </select>
706 - </div>
707 - </div>
708 - <!-- Loading spinner for tunnel operations -->
709 - <div class="loading-spinner" x-show="isLoading">
710 - <span class="icon material-symbols-outlined spin">progress_activity</span>
711 - <span x-text="loadingText || 'Processing tunnel request...'"></span>
712 - </div>
713 -
714 - <!-- Tunnel content when not loading -->
715 - <div x-show="!isLoading">
716 - <!-- Tunnel link display when generated -->
717 - <div class="tunnel-link-container" x-show="linkGenerated">
718 - <div class="tunnel-link-field">
719 - <input type="text" class="tunnel-link-input" :value="tunnelLink" readonly>
720 - <div class="buttons-row">
721 - <button class="copy-link-button" @click="copyToClipboard()" title="Copy to clipboard">
722 - <span class="icon material-symbols-outlined">content_copy</span> Copy
723 - </button>
724 - <button class="refresh-link-button" @click="refreshLink()" title="Generate new URL">
725 - <span class="icon material-symbols-outlined">refresh</span> Refresh
726 - </button>
727 - </div>
728 - </div>
729 - <div class="tunnel-qr-container">
730 - <div class="tunnel-qr-code" id="qrcode-tunnel">
731 - <!-- QR code will be generated here -->
732 - </div>
733 - <div class="tunnel-qr-label">Scan with mobile device</div>
734 - </div>
735 - <div class="tunnel-link-info">
736 - Share this URL to allow others to access your Agent Zero instance.
737 - </div>
738 - <div class="tunnel-link-persistence">
739 - This URL will persist until you stop the tunnel or restart the Docker container.
740 - </div>
741 - <div class="stop-tunnel-container">
742 - <button class="btn btn-danger" @click="stopTunnel()">
743 - <span class="icon material-symbols-outlined">stop_circle</span> Stop Tunnel
744 - </button>
745 - </div>
746 - </div>
747 - <!-- Generate tunnel button when no link exists -->
748 - <div class="tunnel-actions" x-show="!linkGenerated">
749 - <button class="btn btn-ok" @click="generateLink()">
750 - <span class="icon material-symbols-outlined">play_circle</span> Create Tunnel
751 - </button>
752 - </div>
753 - </div>
754 - </div>
691 + <x-component path="settings/tunnel/tunnel-section.html" />
692 </div>
693 </div>
694
webui/js/settings.js
-13
@@ -67,19 +67,6 @@ const settingsModalProxy = {
67 }
68 }
69 }
70 -
71 - // When switching to the tunnel tab, initialize tunnelSettings
72 - if (tabName === 'tunnel') {
73 - console.log('Switching to tunnel tab, initializing tunnelSettings');
74 - const tunnelElement = document.querySelector('[x-data="tunnelSettings"]');
75 - if (tunnelElement) {
76 - const tunnelData = Alpine.$data(tunnelElement);
77 - if (tunnelData && typeof tunnelData.checkTunnelStatus === 'function') {
78 - // Check tunnel status
79 - tunnelData.checkTunnelStatus();
80 - }
81 - }
82 - }
70 }, 10);
71 },
72
webui/js/tunnel.js deleted
-386
@@ -1,386 +0,0 @@
1 -
2 -// Tunnel settings for the Settings modal
3 -document.addEventListener('alpine:init', () => {
4 - Alpine.data('tunnelSettings', () => ({
5 - isLoading: false,
6 - tunnelLink: '',
7 - linkGenerated: false,
8 - loadingText: '',
9 - qrCodeInstance: null,
10 -
11 - init() {
12 - this.checkTunnelStatus();
13 - },
14 -
15 - generateQRCode() {
16 - if (!this.tunnelLink) return;
17 -
18 - const qrContainer = document.getElementById('qrcode-tunnel');
19 - if (!qrContainer) return;
20 -
21 - // Clear any existing QR code
22 - qrContainer.innerHTML = '';
23 -
24 - try {
25 - // Generate new QR code
26 - this.qrCodeInstance = new QRCode(qrContainer, {
27 - text: this.tunnelLink,
28 - width: 128,
29 - height: 128,
30 - colorDark: "#000000",
31 - colorLight: "#ffffff",
32 - correctLevel: QRCode.CorrectLevel.M
33 - });
34 - } catch (error) {
35 - console.error('Error generating QR code:', error);
36 - qrContainer.innerHTML = '<div class="qr-error">QR code generation failed</div>';
37 - }
38 - },
39 -
40 - async checkTunnelStatus() {
41 - try {
42 - const response = await fetchApi('/tunnel_proxy', {
43 - method: 'POST',
44 - headers: {
45 - 'Content-Type': 'application/json',
46 - },
47 - body: JSON.stringify({ action: 'get' }),
48 - });
49 -
50 - const data = await response.json();
51 -
52 - if (data.success && data.tunnel_url) {
53 - // Update the stored URL if it's different from what we have
54 - if (this.tunnelLink !== data.tunnel_url) {
55 - this.tunnelLink = data.tunnel_url;
56 - localStorage.setItem('agent_zero_tunnel_url', data.tunnel_url);
57 - }
58 - this.linkGenerated = true;
59 - // Generate QR code for the tunnel URL
60 - this.$nextTick(() => this.generateQRCode());
61 - } else {
62 - // Check if we have a stored tunnel URL
63 - const storedTunnelUrl = localStorage.getItem('agent_zero_tunnel_url');
64 -
65 - if (storedTunnelUrl) {
66 - // Use the stored URL but verify it's still valid
67 - const verifyResponse = await fetchApi('/tunnel_proxy', {
68 - method: 'POST',
69 - headers: {
70 - 'Content-Type': 'application/json',
71 - },
72 - body: JSON.stringify({ action: 'verify', url: storedTunnelUrl }),
73 - });
74 -
75 - const verifyData = await verifyResponse.json();
76 -
77 - if (verifyData.success && verifyData.is_valid) {
78 - this.tunnelLink = storedTunnelUrl;
79 - this.linkGenerated = true;
80 - // Generate QR code for the tunnel URL
81 - this.$nextTick(() => this.generateQRCode());
82 - } else {
83 - // Clear stale URL
84 - localStorage.removeItem('agent_zero_tunnel_url');
85 - this.tunnelLink = '';
86 - this.linkGenerated = false;
87 - }
88 - } else {
89 - // No stored URL, show the generate button
90 - this.tunnelLink = '';
91 - this.linkGenerated = false;
92 - }
93 - }
94 - } catch (error) {
95 - console.error('Error checking tunnel status:', error);
96 - this.tunnelLink = '';
97 - this.linkGenerated = false;
98 - }
99 - },
100 -
101 - async refreshLink() {
102 - // Call generate but with a confirmation first
103 - if (confirm("Are you sure you want to generate a new tunnel URL? The old URL will no longer work.")) {
104 - this.isLoading = true;
105 - this.loadingText = 'Refreshing tunnel...';
106 -
107 - // Change refresh button appearance
108 - const refreshButton = document.querySelector('.refresh-link-button');
109 - const originalContent = refreshButton.innerHTML;
110 - refreshButton.innerHTML = '<span class="icon material-symbols-outlined spin">progress_activity</span> Refreshing...';
111 - refreshButton.disabled = true;
112 - refreshButton.classList.add('refreshing');
113 -
114 - try {
115 - // First stop any existing tunnel
116 - const stopResponse = await fetchApi('/tunnel_proxy', {
117 - method: 'POST',
118 - headers: {
119 - 'Content-Type': 'application/json',
120 - },
121 - body: JSON.stringify({ action: 'stop' }),
122 - });
123 -
124 - // Check if stopping was successful
125 - const stopData = await stopResponse.json();
126 - if (!stopData.success) {
127 - console.warn("Warning: Couldn't stop existing tunnel cleanly");
128 - // Continue anyway since we want to create a new one
129 - }
130 -
131 - // Then generate a new one
132 - await this.generateLink();
133 - } catch (error) {
134 - console.error("Error refreshing tunnel:", error);
135 - window.toastFrontendError("Error refreshing tunnel", "Tunnel Error");
136 - this.isLoading = false;
137 - this.loadingText = '';
138 - } finally {
139 - // Reset refresh button
140 - refreshButton.innerHTML = originalContent;
141 - refreshButton.disabled = false;
142 - refreshButton.classList.remove('refreshing');
143 - }
144 - }
145 - },
146 -
147 - async generateLink() {
148 - // First check if authentication is enabled
149 - try {
150 - const authCheckResponse = await fetchApi('/settings_get');
151 - const authData = await authCheckResponse.json();
152 -
153 - // Find the auth_login and auth_password in the settings
154 - let hasAuth = false;
155 -
156 - if (authData && authData.settings && authData.settings.sections) {
157 - for (const section of authData.settings.sections) {
158 - if (section.fields) {
159 - const authLoginField = section.fields.find(field => field.id === 'auth_login');
160 - const authPasswordField = section.fields.find(field => field.id === 'auth_password');
161 -
162 - if (authLoginField && authPasswordField &&
163 - authLoginField.value && authPasswordField.value) {
164 - hasAuth = true;
165 - break;
166 - }
167 - }
168 - }
169 - }
170 -
171 - // If no authentication is set, warn the user
172 - if (!hasAuth) {
173 - const proceed = confirm(
174 - "WARNING: No authentication is configured for your Agent Zero instance.\n\n" +
175 - "Creating a public tunnel without authentication means anyone with the URL " +
176 - "can access your Agent Zero instance.\n\n" +
177 - "It is recommended to set up authentication in the Settings > Authentication section " +
178 - "before creating a public tunnel.\n\n" +
179 - "Do you want to proceed anyway?"
180 - );
181 -
182 - if (!proceed) {
183 - return; // User cancelled
184 - }
185 - }
186 - } catch (error) {
187 - console.error("Error checking authentication status:", error);
188 - // Continue anyway if we can't check auth status
189 - }
190 -
191 - this.isLoading = true;
192 - this.loadingText = 'Creating tunnel...';
193 -
194 - // Get provider from the parent settings modal scope
195 - const modalEl = document.getElementById('settingsModal');
196 - const modalAD = Alpine.$data(modalEl);
197 - const provider = modalAD.provider || 'cloudflared'; // Default to cloudflared if not set
198 -
199 - // Change create button appearance
200 - const createButton = document.querySelector('.tunnel-actions .btn-ok');
201 - if (createButton) {
202 - createButton.innerHTML = '<span class="icon material-symbols-outlined spin">progress_activity</span> Creating...';
203 - createButton.disabled = true;
204 - createButton.classList.add('creating');
205 - }
206 -
207 - try {
208 - // Call the backend API to create a tunnel
209 - const response = await fetchApi('/tunnel_proxy', {
210 - method: 'POST',
211 - headers: {
212 - 'Content-Type': 'application/json',
213 - },
214 - body: JSON.stringify({
215 - action: 'create',
216 - provider: provider
217 - // port: window.location.port || (window.location.protocol === 'https:' ? 443 : 80)
218 - }),
219 - });
220 -
221 - const data = await response.json();
222 -
223 - if (data.success && data.tunnel_url) {
224 - // Store the tunnel URL in localStorage for persistence
225 - localStorage.setItem('agent_zero_tunnel_url', data.tunnel_url);
226 -
227 - this.tunnelLink = data.tunnel_url;
228 - this.linkGenerated = true;
229 -
230 - // Generate QR code for the tunnel URL
231 - this.$nextTick(() => this.generateQRCode());
232 -
233 - // Show success message to confirm creation
234 - window.toastFrontendInfo("Tunnel created successfully", "Tunnel Status");
235 - } else {
236 - // The tunnel might still be starting up, check again after a delay
237 - this.loadingText = 'Tunnel creation taking longer than expected...';
238 -
239 - // Wait for 5 seconds and check if the tunnel is running
240 - await new Promise(resolve => setTimeout(resolve, 5000));
241 -
242 - // Check if tunnel is running now
243 - try {
244 - const statusResponse = await fetchApi('/tunnel_proxy', {
245 - method: 'POST',
246 - headers: {
247 - 'Content-Type': 'application/json',
248 - },
249 - body: JSON.stringify({ action: 'get' }),
250 - });
251 -
252 - const statusData = await statusResponse.json();
253 -
254 - if (statusData.success && statusData.tunnel_url) {
255 - // Tunnel is now running, we can update the UI
256 - localStorage.setItem('agent_zero_tunnel_url', statusData.tunnel_url);
257 - this.tunnelLink = statusData.tunnel_url;
258 - this.linkGenerated = true;
259 -
260 - // Generate QR code for the tunnel URL
261 - this.$nextTick(() => this.generateQRCode());
262 -
263 - window.toastFrontendInfo("Tunnel created successfully", "Tunnel Status");
264 - return;
265 - }
266 - } catch (statusError) {
267 - console.error("Error checking tunnel status:", statusError);
268 - }
269 -
270 - // If we get here, the tunnel really failed to start
271 - const errorMessage = data.message || "Failed to create tunnel. Please try again.";
272 - window.toastFrontendError(errorMessage, "Tunnel Error");
273 - console.error("Tunnel creation failed:", data);
274 - }
275 - } catch (error) {
276 - window.toastFrontendError("Error creating tunnel", "Tunnel Error");
277 - console.error("Error creating tunnel:", error);
278 - } finally {
279 - this.isLoading = false;
280 - this.loadingText = '';
281 -
282 - // Reset create button if it's still in the DOM
283 - const createButton = document.querySelector('.tunnel-actions .btn-ok');
284 - if (createButton) {
285 - createButton.innerHTML = '<span class="icon material-symbols-outlined">play_circle</span> Create Tunnel';
286 - createButton.disabled = false;
287 - createButton.classList.remove('creating');
288 - }
289 - }
290 - },
291 -
292 - async stopTunnel() {
293 - if (confirm("Are you sure you want to stop the tunnel? The URL will no longer be accessible.")) {
294 - this.isLoading = true;
295 - this.loadingText = 'Stopping tunnel...';
296 -
297 -
298 - try {
299 - // Call the backend to stop the tunnel
300 - const response = await fetchApi('/tunnel_proxy', {
301 - method: 'POST',
302 - headers: {
303 - 'Content-Type': 'application/json',
304 - },
305 - body: JSON.stringify({ action: 'stop' }),
306 - });
307 -
308 - const data = await response.json();
309 -
310 - if (data.success) {
311 - // Clear the stored URL
312 - localStorage.removeItem('agent_zero_tunnel_url');
313 -
314 - // Clear QR code
315 - const qrContainer = document.getElementById('qrcode-tunnel');
316 - if (qrContainer) {
317 - qrContainer.innerHTML = '';
318 - }
319 - this.qrCodeInstance = null;
320 -
321 - // Update UI state
322 - this.tunnelLink = '';
323 - this.linkGenerated = false;
324 -
325 - window.toastFrontendInfo("Tunnel stopped successfully", "Tunnel Status");
326 - } else {
327 - window.toastFrontendError("Failed to stop tunnel", "Tunnel Error");
328 -
329 - // Reset stop button
330 - stopButton.innerHTML = originalStopContent;
331 - stopButton.disabled = false;
332 - stopButton.classList.remove('stopping');
333 - }
334 - } catch (error) {
335 - window.toastFrontendError("Error stopping tunnel", "Tunnel Error");
336 - console.error("Error stopping tunnel:", error);
337 -
338 - // Reset stop button
339 - stopButton.innerHTML = originalStopContent;
340 - stopButton.disabled = false;
341 - stopButton.classList.remove('stopping');
342 - } finally {
343 - this.isLoading = false;
344 - this.loadingText = '';
345 - }
346 - }
347 - },
348 -
349 - copyToClipboard() {
350 - if (!this.tunnelLink) return;
351 -
352 - const copyButton = document.querySelector('.copy-link-button');
353 - const originalContent = copyButton.innerHTML;
354 -
355 - navigator.clipboard.writeText(this.tunnelLink)
356 - .then(() => {
357 - // Update button to show success state
358 - copyButton.innerHTML = '<span class="icon material-symbols-outlined">check</span> Copied!';
359 - copyButton.classList.add('copy-success');
360 -
361 - // Show toast notification
362 - window.toastFrontendInfo("Tunnel URL copied to clipboard!", "Clipboard");
363 -
364 - // Reset button after 2 seconds
365 - setTimeout(() => {
366 - copyButton.innerHTML = originalContent;
367 - copyButton.classList.remove('copy-success');
368 - }, 2000);
369 - })
370 - .catch(err => {
371 - console.error('Failed to copy URL: ', err);
372 - window.toastFrontendError("Failed to copy tunnel URL", "Clipboard Error");
373 -
374 - // Show error state
375 - copyButton.innerHTML = '<span class="icon material-symbols-outlined">close</span> Failed';
376 - copyButton.classList.add('copy-error');
377 -
378 - // Reset button after 2 seconds
379 - setTimeout(() => {
380 - copyButton.innerHTML = originalContent;
381 - copyButton.classList.remove('copy-error');
382 - }, 2000);
383 - });
384 - }
385 - }));
386 -});