unitlab 2.3.33__py3-none-any.whl → 2.3.34__py3-none-any.whl

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.
unitlab/client.py CHANGED
@@ -12,12 +12,8 @@ import subprocess
12
12
  import signal
13
13
  import re
14
14
  import time
15
- import threading
16
15
  import psutil
17
16
  from datetime import datetime, timezone
18
- from .tunnel_config import CloudflareTunnel
19
- from .cloudflare_api_tunnel import CloudflareAPITunnel
20
- from .simple_tunnel import SimpleTunnel
21
17
  from .utils import get_api_url, handle_exceptions
22
18
  from pathlib import Path
23
19
 
@@ -29,15 +25,6 @@ except ImportError:
29
25
  HAS_GPU = False
30
26
 
31
27
 
32
- try:
33
- from dotenv import load_dotenv
34
- env_path = Path(__file__).parent.parent.parent / '.env'
35
- if env_path.exists():
36
- load_dotenv(env_path)
37
- except ImportError:
38
- pass # dotenv not installed, use system env vars only
39
-
40
-
41
28
  logger = logging.getLogger(__name__)
42
29
 
43
30
  class UnitlabClient:
@@ -88,6 +75,7 @@ class UnitlabClient:
88
75
  self.hostname = socket.gethostname()
89
76
  self.tunnel_manager = None
90
77
  self.jupyter_url = None
78
+ self.api_expose_url = None
91
79
  self.ssh_url = None
92
80
  self.jupyter_proc = None
93
81
  self.tunnel_proc = None
@@ -289,6 +277,8 @@ class UnitlabClient:
289
277
  # Don't call run() here - it has infinite loop. Call start() in setup_tunnels()
290
278
  self.jupyter_url = None
291
279
  self.ssh_url = None
280
+ self.api_url = None
281
+
292
282
 
293
283
  except ImportError as e:
294
284
  logger.warning(f"Could not import PersistentTunnel: {e}")
@@ -399,26 +389,34 @@ class UnitlabClient:
399
389
 
400
390
  logger.info("Setting up Cloudflare tunnel...")
401
391
 
402
- # Both SimpleTunnel and AutoTunnel handle Jupyter internally
392
+
403
393
  if self.tunnel_manager.start():
404
- # Store the processes for monitoring
394
+
405
395
  self.jupyter_proc = self.tunnel_manager.jupyter_process
406
- # Update URLs after tunnel starts successfully
396
+
407
397
  self.jupyter_url = self.tunnel_manager.jupyter_url
408
- self.ssh_url = self.tunnel_manager.jupyter_url
398
+ self.api_expose_url = self.tunnel_manager.api_expose_url
409
399
  logger.info(f"Tunnel started successfully at {self.jupyter_url}")
410
400
  self.tunnel_proc = self.tunnel_manager.tunnel_process
411
401
  self.jupyter_port = "8888" # Both use fixed port
412
402
 
413
- # Get the URL (AutoTunnel generates it dynamically)
403
+
404
+ if hasattr(self.tunnel_manager, 'ssh_url'):
405
+ self.ssh_url = self.tunnel_manager.ssh_url
406
+ else:
407
+
408
+ self.ssh_url = self.jupyter_url
409
+
410
+
414
411
  if hasattr(self.tunnel_manager, 'tunnel_url') and self.tunnel_manager.tunnel_url:
415
412
  self.jupyter_url = self.tunnel_manager.tunnel_url
416
- self.ssh_url = self.tunnel_manager.tunnel_url
413
+ if not hasattr(self.tunnel_manager, 'ssh_url'):
414
+ self.ssh_url = self.tunnel_manager.tunnel_url
417
415
  elif hasattr(self.tunnel_manager, 'jupyter_url'):
418
416
  self.jupyter_url = self.tunnel_manager.jupyter_url
419
- self.ssh_url = self.tunnel_manager.jupyter_url
417
+ if not hasattr(self.tunnel_manager, 'ssh_url'):
418
+ self.ssh_url = self.tunnel_manager.jupyter_url
420
419
 
421
- # The tunnel is now running
422
420
  logger.info("✅ Tunnel and Jupyter established")
423
421
  logger.info("URL: {}".format(self.jupyter_url))
424
422
  self.report_services()
@@ -470,12 +468,15 @@ class UnitlabClient:
470
468
 
471
469
  logger.info(f"Reporting Jupyter service with URL: {self.jupyter_url}")
472
470
  logger.debug(f"API key present: {bool(self.api_key)}")
471
+
473
472
  if self.api_key:
474
473
  logger.debug(f"API key value: {self.api_key[:8]}...")
474
+
475
475
  jupyter_response = self._post_device(
476
476
  f"/api/tunnel/agent/jupyter/{self.device_id}/",
477
477
  jupyter_data
478
478
  )
479
+
479
480
  logger.info(f"Reported Jupyter service: {jupyter_response.status_code if hasattr(jupyter_response, 'status_code') else jupyter_response}")
480
481
 
481
482
  # Report SSH service (always report, even if SSH is not running locally)
@@ -506,7 +507,22 @@ class UnitlabClient:
506
507
  ssh_data
507
508
  )
508
509
  logger.info(f"Reported SSH service: {ssh_response.status_code if hasattr(ssh_response, 'status_code') else ssh_response}")
510
+ logger.info("Reporting API endpoint:")
509
511
 
512
+ api_expose_data = {
513
+ "service_type": "api",
514
+ "service_name": f"api-{self.device_id}",
515
+ 'local_port': None,
516
+ 'tunnel_url': self.api_expose_url,
517
+ 'status': 'online'
518
+ }
519
+
520
+ api_expose_response = self._post_device(
521
+ f"/api/tunnel/agent/api-url/{self.device_id}/",
522
+ api_expose_data
523
+ )
524
+ logger.info(f"Reported Api service: {api_expose_response.status_code if hasattr(api_expose_response, 'status_code') else api_expose_response}")
525
+
510
526
  except Exception as e:
511
527
  logger.error(f"Failed to report services: {e}", exc_info=True)
512
528
 
@@ -550,55 +566,55 @@ class UnitlabClient:
550
566
 
551
567
  return metrics
552
568
 
553
- def send_metrics(self):
554
- """Send metrics to server"""
555
- try:
556
- metrics = self.collect_metrics()
569
+ # def send_metrics(self):
570
+ # """Send metrics to server"""
571
+ # try:
572
+ # metrics = self.collect_metrics()
557
573
 
558
- # Send CPU metrics
559
- if 'cpu' in metrics:
560
- self._post_device(f"/api/tunnel/agent/cpu/{self.device_id}/", metrics['cpu'])
574
+ # # Send CPU metrics
575
+ # if 'cpu' in metrics:
576
+ # self._post_device(f"/api/tunnel/agent/cpu/{self.device_id}/", metrics['cpu'])
561
577
 
562
- # Send RAM metrics
563
- if 'ram' in metrics:
564
- self._post_device(f"/api/tunnel/agent/ram/{self.device_id}/", metrics['ram'])
578
+ # # Send RAM metrics
579
+ # if 'ram' in metrics:
580
+ # self._post_device(f"/api/tunnel/agent/ram/{self.device_id}/", metrics['ram'])
565
581
 
566
- # Send GPU metrics if available
567
- if 'gpu' in metrics and metrics['gpu']:
568
- self._post_device(f"/api/tunnel/agent/gpu/{self.device_id}/", metrics['gpu'])
582
+ # # Send GPU metrics if available
583
+ # if 'gpu' in metrics and metrics['gpu']:
584
+ # self._post_device(f"/api/tunnel/agent/gpu/{self.device_id}/", metrics['gpu'])
569
585
 
570
- logger.debug(f"Metrics sent - CPU: {metrics['cpu']['percent']:.1f}%, RAM: {metrics['ram']['percent']:.1f}%")
586
+ # logger.debug(f"Metrics sent - CPU: {metrics['cpu']['percent']:.1f}%, RAM: {metrics['ram']['percent']:.1f}%")
571
587
 
572
- except Exception as e:
573
- logger.error(f"Failed to send metrics: {e}")
588
+ # except Exception as e:
589
+ # logger.error(f"Failed to send metrics: {e}")
574
590
 
575
- def metrics_loop(self):
576
- """Background thread for sending metrics"""
577
- logger.info("Starting metrics thread")
591
+ # def metrics_loop(self):
592
+ # """Background thread for sending metrics"""
593
+ # logger.info("Starting metrics thread")
578
594
 
579
- while self.running:
580
- try:
581
- self.send_metrics()
595
+ # while self.running:
596
+ # try:
597
+ # self.send_metrics()
582
598
 
583
- # Check if processes are still running
584
- if self.jupyter_proc and self.jupyter_proc.poll() is not None:
585
- logger.warning("Jupyter process died")
586
- self.jupyter_proc = None
599
+ # # Check if processes are still running
600
+ # if self.jupyter_proc and self.jupyter_proc.poll() is not None:
601
+ # logger.warning("Jupyter process died")
602
+ # self.jupyter_proc = None
587
603
 
588
- if self.tunnel_proc and self.tunnel_proc.poll() is not None:
589
- logger.warning("Tunnel process died")
590
- self.tunnel_proc = None
604
+ # if self.tunnel_proc and self.tunnel_proc.poll() is not None:
605
+ # logger.warning("Tunnel process died")
606
+ # self.tunnel_proc = None
591
607
 
592
- except Exception as e:
593
- logger.error(f"Metrics loop error: {e}")
608
+ # except Exception as e:
609
+ # logger.error(f"Metrics loop error: {e}")
594
610
 
595
- # Wait for next interval (default 5 seconds)
596
- for _ in range(3):
597
- if not self.running:
598
- break
599
- time.sleep(1)
611
+ # # Wait for next interval (default 5 seconds)
612
+ # for _ in range(3):
613
+ # if not self.running:
614
+ # break
615
+ # time.sleep(1)
600
616
 
601
- logger.info("Metrics thread stopped")
617
+ # logger.info("Metrics thread stopped")
602
618
 
603
619
  def run_device_agent(self):
604
620
  """Main run method for device agent"""
@@ -612,17 +628,16 @@ class UnitlabClient:
612
628
  # Check SSH
613
629
  self.check_ssh()
614
630
 
615
- # SimpleTunnel handles Jupyter internally, so we don't start it separately
616
- # Just setup the tunnels which will also start Jupyter
631
+
617
632
  logger.info("Starting integrated Jupyter and tunnel...")
618
633
 
619
- # Setup tunnels
634
+
620
635
  if not self.setup_tunnels():
621
636
  logger.error("Failed to setup tunnels")
622
637
  self.cleanup_device_agent()
623
638
  return
624
639
 
625
- # Print access information
640
+
626
641
  logger.info("=" * 50)
627
642
  logger.info("🎉 All services started successfully!")
628
643
  logger.info(f"📔 Jupyter: {self.jupyter_url}")
@@ -635,8 +650,8 @@ class UnitlabClient:
635
650
  logger.info("=" * 50)
636
651
 
637
652
  # Start metrics thread
638
- self.metrics_thread = threading.Thread(target=self.metrics_loop, daemon=True)
639
- self.metrics_thread.start()
653
+ # self.metrics_thread = threading.Thread(target=self.metrics_loop, daemon=True)
654
+ # self.metrics_thread.start()
640
655
 
641
656
  # Main loop
642
657
  try:
unitlab/main.py CHANGED
@@ -2,16 +2,21 @@ from enum import Enum
2
2
  from pathlib import Path
3
3
  from uuid import UUID
4
4
  import logging
5
- import os
5
+ import threading
6
6
 
7
7
  import typer
8
8
  import validators
9
9
  from typing_extensions import Annotated
10
+ import psutil
11
+ import uvicorn
12
+ from fastapi import FastAPI,Response
13
+
10
14
 
11
15
  from . import utils
12
16
  from .client import UnitlabClient
13
17
 
14
18
 
19
+
15
20
  app = typer.Typer()
16
21
  project_app = typer.Typer()
17
22
  dataset_app = typer.Typer()
@@ -45,6 +50,8 @@ class AnnotationType(str, Enum):
45
50
  IMG_POINT = "img_point"
46
51
 
47
52
 
53
+
54
+
48
55
  @app.command(help="Configure the credentials")
49
56
  def configure(
50
57
  api_key: Annotated[str, typer.Option(help="The api-key obtained from unitlab.ai")],
@@ -111,18 +118,6 @@ def dataset_download(
111
118
  get_client(api_key).dataset_download_files(pk)
112
119
 
113
120
 
114
- def send_metrics_to_server(server_url: str, device_id: str, metrics: dict):
115
- """Standalone function to send metrics to server using client"""
116
- client = UnitlabClient(api_key="dummy") # API key not needed for metrics
117
- return client.send_metrics_to_server(server_url, device_id, metrics)
118
-
119
-
120
- def send_metrics_into_server():
121
- """Standalone function to collect system metrics using client"""
122
- client = UnitlabClient(api_key="dummy") # API key not needed for metrics
123
- return client.collect_system_metrics()
124
-
125
-
126
121
  @agent_app.command(name="run", help="Run the device agent with Jupyter, SSH tunnels and metrics")
127
122
  def run_agent(
128
123
  api_key: API_KEY,
@@ -130,42 +125,32 @@ def run_agent(
130
125
  base_domain: Annotated[str, typer.Option(help="Base domain for tunnels")] = "1scan.uz",
131
126
 
132
127
  ):
133
- """Run the full device agent with Jupyter, SSH tunnels and metrics reporting"""
134
-
135
- # Setup logging
128
+
136
129
  logging.basicConfig(
137
130
  level=logging.INFO,
138
131
  format='%(asctime)s - %(levelname)s - %(message)s',
139
132
  handlers=[logging.StreamHandler()]
140
133
  )
141
134
 
142
- # Get server URL from environment or use default
143
- server_url = 'https://api-dev.unitlab.ai/'
135
+ server_url = 'http://localhost:8000/'
144
136
 
145
- # Generate unique device ID if not provided
146
137
  if not device_id:
147
138
  import uuid
148
139
  import platform
149
- from pathlib import Path
150
-
151
- # Try environment variable first
152
- device_id = os.getenv('DEVICE_ID')
153
- if not device_id:
154
- # Always generate a unique device ID (no saving/reusing)
155
- hostname = platform.node().replace('.', '-').replace(' ', '-')[:20]
156
- random_suffix = str(uuid.uuid4())[:8]
157
- device_id = f"{hostname}-{random_suffix}"
158
- print(f"📝 Generated unique device ID: {device_id}")
140
+
141
+ hostname = platform.node().replace('.', '-').replace(' ', '-')[:20]
142
+ random_suffix = str(uuid.uuid4())[:8]
143
+ device_id = f"{hostname}-{random_suffix}"
144
+ print(f"📝 Generated unique device ID: {device_id}")
159
145
 
160
-
161
- # Create client and initialize device agent
146
+
162
147
  client = UnitlabClient(api_key=api_key)
163
148
  client.initialize_device_agent(
164
149
  server_url=server_url,
165
150
  device_id=device_id,
166
151
  base_domain=base_domain
167
152
  )
168
-
153
+
169
154
  try:
170
155
  client.run_device_agent()
171
156
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Persistent Tunnel - Each device gets deviceid.1scan.uz
3
+ Persistent Tunnel - Each device gets deviceid.unitlab-ai.com
4
4
  Uses Cloudflare API to create named tunnels
5
5
  """
6
6
 
@@ -10,18 +10,32 @@ import json
10
10
  import time
11
11
  import os
12
12
  import base64
13
+ from fastapi import FastAPI
14
+ import uvicorn
15
+ import threading
16
+ import psutil
17
+
18
+
19
+ api = FastAPI()
20
+
21
+ @api.get("/api-agent/")
22
+ def get_cpu_info():
23
+ cpu_usage_percent = psutil.cpu_percent(interval=1)
24
+ ram = psutil.virtual_memory()
25
+ return {"cpu_percentage": cpu_usage_percent, 'cpu_count': psutil.cpu_count(), 'ram_usage': ram.used }
26
+
13
27
 
14
28
  class PersistentTunnel:
15
29
  def __init__(self, device_id=None):
16
30
  """Initialize with device ID"""
17
31
 
18
32
  # Cloudflare credentials (hardcoded for simplicity)
19
- self.cf_email = "uone2323@gmail.com"
20
- self.cf_api_key = "1c634bd17ca6ade0eb91966323589fd98c72e" # Global API Key
33
+
34
+ self.cf_api_key = "RoIAn1t9rMqcGK7_Xja216pxbRTyFafC1jeRKIO3"
21
35
 
22
36
  # Account and Zone IDs
23
- self.cf_account_id = "c91192ae20a5d43f65e087550d8dc89b" # Your account ID
24
- self.cf_zone_id = "78182c3883adad79d8f1026851a68176" # Zone ID for 1scan.uz
37
+ self.cf_account_id = "29df28cf48a30be3b1aa344b840400e6" # Your account ID
38
+ self.cf_zone_id = "eae80a730730b3b218a80dace996535a" # Zone ID for unitlab-ai.com
25
39
 
26
40
  # Clean device ID for subdomain
27
41
  if device_id:
@@ -32,16 +46,19 @@ class PersistentTunnel:
32
46
 
33
47
  self.tunnel_name = "agent-{}".format(self.device_id)
34
48
  self.subdomain = self.device_id
35
- self.domain = "1scan.uz"
49
+ self.domain = "unitlab-ai.com"
36
50
  self.jupyter_url = "https://{}.{}".format(self.subdomain, self.domain)
37
-
51
+ self.api_expose_url = "https://{}.{}/api-agent/".format(self.subdomain, self.domain)
52
+ self.ssh_subdomain = "s{}".format(self.device_id) # Shorter SSH subdomain to avoid length issues
53
+ self.ssh_url = "{}.{}".format(self.ssh_subdomain, self.domain) # SSH on s{deviceid}.unitlab-ai.com
54
+
38
55
  self.tunnel_id = None
39
56
  self.tunnel_credentials = None
40
57
  self.jupyter_process = None
41
58
  self.tunnel_process = None
42
59
 
43
60
  def get_zone_id(self):
44
- """Get Zone ID for 1scan.uz"""
61
+ """Get Zone ID for unitlab-ai.com"""
45
62
  print("🔍 Getting Zone ID for {}...".format(self.domain))
46
63
 
47
64
  url = "https://api.cloudflare.com/client/v4/zones"
@@ -61,11 +78,12 @@ class PersistentTunnel:
61
78
 
62
79
  def _get_headers(self):
63
80
  """Get API headers for Global API Key"""
64
- return {
65
- "X-Auth-Email": self.cf_email,
66
- "X-Auth-Key": self.cf_api_key,
67
- "Content-Type": "application/json"
68
- }
81
+
82
+
83
+ return {
84
+ "Authorization": f"Bearer {self.cf_api_key}",
85
+ "Content-Type": "application/json"
86
+ }
69
87
 
70
88
  def get_or_create_tunnel(self):
71
89
  """Get existing tunnel or create a new one"""
@@ -133,15 +151,15 @@ class PersistentTunnel:
133
151
  print("✅ Tunnel created: {}".format(self.tunnel_id))
134
152
  return cred_file
135
153
  else:
136
- print("❌ Failed to create tunnel: {}".format(response.text[:200]))
154
+ print("❌ Failed to create tunnel: {}".format(response.text))
137
155
  return None
138
156
 
139
157
  def create_dns_record(self):
140
- """Create DNS CNAME record"""
158
+ """Create DNS CNAME records for main domain and SSH subdomain"""
141
159
  if not self.tunnel_id:
142
160
  return False
143
161
 
144
- print("🔧 Creating DNS record: {}.{}...".format(self.subdomain, self.domain))
162
+ print("🔧 Creating DNS records...")
145
163
 
146
164
  # Get zone ID if we don't have it
147
165
  if self.cf_zone_id == "NEED_ZONE_ID_FOR_1SCAN_UZ":
@@ -150,6 +168,7 @@ class PersistentTunnel:
150
168
  url = "https://api.cloudflare.com/client/v4/zones/{}/dns_records".format(self.cf_zone_id)
151
169
  headers = self._get_headers()
152
170
 
171
+ # Create main subdomain record for Jupyter and API
153
172
  data = {
154
173
  "type": "CNAME",
155
174
  "name": self.subdomain,
@@ -161,14 +180,33 @@ class PersistentTunnel:
161
180
  response = requests.post(url, headers=headers, json=data)
162
181
 
163
182
  if response.status_code in [200, 201]:
164
- print("✅ DNS record created")
165
- return True
183
+ print("✅ Main DNS record created: {}.{}".format(self.subdomain, self.domain))
166
184
  elif "already exists" in response.text:
167
- print("⚠️ DNS record already exists")
168
- return True
185
+ print("⚠️ Main DNS record already exists: {}.{}".format(self.subdomain, self.domain))
169
186
  else:
170
- print("❌ Failed to create DNS: {}".format(response.text[:200]))
187
+ print("❌ Failed to create main DNS: {}".format(response.text[:200]))
171
188
  return False
189
+
190
+ # Create SSH subdomain record (s{deviceid}.unitlab-ai.com)
191
+ ssh_data = {
192
+ "type": "CNAME",
193
+ "name": self.ssh_subdomain,
194
+ "content": "{}.cfargotunnel.com".format(self.tunnel_id),
195
+ "proxied": True,
196
+ "ttl": 1
197
+ }
198
+
199
+ ssh_response = requests.post(url, headers=headers, json=ssh_data)
200
+
201
+ if ssh_response.status_code in [200, 201]:
202
+ print("✅ SSH DNS record created: {}.{}".format(self.ssh_subdomain, self.domain))
203
+ elif "already exists" in ssh_response.text:
204
+ print("⚠️ SSH DNS record already exists: {}.{}".format(self.ssh_subdomain, self.domain))
205
+ else:
206
+ print("⚠️ Could not create SSH DNS: {}".format(ssh_response.text[:200]))
207
+ # SSH is optional, so we continue even if SSH DNS fails
208
+
209
+ return True
172
210
 
173
211
  def create_tunnel_config(self, cred_file):
174
212
  """Create tunnel config file"""
@@ -177,11 +215,24 @@ class PersistentTunnel:
177
215
  f.write("tunnel: {}\n".format(self.tunnel_id))
178
216
  f.write("credentials-file: {}\n\n".format(cred_file))
179
217
  f.write("ingress:\n")
218
+
219
+ # SSH service on dedicated subdomain (s{deviceid}.unitlab-ai.com)
220
+ f.write(" - hostname: {}.{}\n".format(self.ssh_subdomain, self.domain))
221
+ f.write(" service: ssh://localhost:22\n")
222
+
223
+ # API (more specific path goes first)
224
+ f.write(" - hostname: {}.{}\n".format(self.subdomain, self.domain))
225
+ f.write(" path: /api-agent/*\n")
226
+ f.write(" service: http://localhost:8001\n")
227
+
228
+ # Jupyter (general hostname for HTTP)
180
229
  f.write(" - hostname: {}.{}\n".format(self.subdomain, self.domain))
181
230
  f.write(" service: http://localhost:8888\n")
231
+
232
+ # Catch-all 404 (MUST be last!)
182
233
  f.write(" - service: http_status:404\n")
183
-
184
- return config_file
234
+ return config_file
235
+
185
236
 
186
237
  def get_cloudflared_path(self):
187
238
  """Get or download cloudflared for any platform"""
@@ -255,6 +306,9 @@ class PersistentTunnel:
255
306
  print("✅ cloudflared downloaded successfully")
256
307
  return local_bin
257
308
 
309
+
310
+
311
+
258
312
  def start_jupyter(self):
259
313
  """Start Jupyter"""
260
314
  print("🚀 Starting Jupyter...")
@@ -274,31 +328,59 @@ class PersistentTunnel:
274
328
  self.jupyter_process = subprocess.Popen(
275
329
  cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
276
330
  )
277
-
331
+
278
332
  time.sleep(3)
279
333
  print("✅ Jupyter started")
280
334
  return True
281
335
 
282
- def start_tunnel(self, config_file):
283
- """Start tunnel with config"""
284
- print("🔧 Starting tunnel...")
285
-
286
- cloudflared = self.get_cloudflared_path()
287
-
288
- cmd = [
289
- cloudflared,
290
- "tunnel",
291
- "--config", config_file,
292
- "run"
293
- ]
294
-
295
- self.tunnel_process = subprocess.Popen(
296
- cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
297
- )
298
-
299
- time.sleep(5)
300
- print("✅ Tunnel running at {}".format(self.jupyter_url))
301
- return True
336
+ def start_api(self):
337
+ def run_api():
338
+ uvicorn.run(
339
+ api,
340
+ port=8001
341
+ )
342
+
343
+ api_thread = threading.Thread(target=run_api, daemon=True)
344
+ api_thread.start()
345
+ print('API is started')
346
+
347
+ def start_tunnel(self, config_file):
348
+ """Start tunnel with config"""
349
+ print("🔧 Starting tunnel...")
350
+
351
+ cloudflared = self.get_cloudflared_path()
352
+
353
+ cmd = [
354
+ cloudflared,
355
+ "tunnel",
356
+ "--config", config_file,
357
+ "run"
358
+ ]
359
+
360
+ self.tunnel_process = subprocess.Popen(
361
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
362
+ )
363
+
364
+ time.sleep(2)
365
+
366
+ # Check if process is still running
367
+ if self.tunnel_process.poll() is not None:
368
+ print("❌ Tunnel process died!")
369
+ # Try to read error output
370
+ try:
371
+ stdout, stderr = self.tunnel_process.communicate(timeout=1)
372
+ if stderr:
373
+ print(f"Error: {stderr.decode()}")
374
+ if stdout:
375
+ print(f"Output: {stdout.decode()}")
376
+ except:
377
+ pass
378
+ return False
379
+
380
+ print("✅ Tunnel running at {}".format(self.jupyter_url))
381
+ print("✅ API running at {}".format(self.api_expose_url))
382
+ print("✅ SSH available at {}".format(self.ssh_url))
383
+ return True
302
384
 
303
385
  def start(self):
304
386
  """Main entry point"""
@@ -313,9 +395,7 @@ class PersistentTunnel:
313
395
 
314
396
  # 1. Get existing or create new tunnel via API
315
397
  cred_file = self.get_or_create_tunnel()
316
- if not cred_file:
317
- print("⚠️ Falling back to quick tunnel")
318
- return self.start_quick_tunnel()
398
+
319
399
 
320
400
  # 2. Create DNS record
321
401
  self.create_dns_record()
@@ -325,13 +405,24 @@ class PersistentTunnel:
325
405
 
326
406
  # 4. Start services
327
407
  self.start_jupyter()
408
+ self.start_api()
328
409
  self.start_tunnel(config_file)
329
410
 
330
411
  print("\n" + "="*50)
331
- print("🎉 SUCCESS! Persistent URL created:")
332
- print(" {}".format(self.jupyter_url))
333
- print(" Tunnel ID: {}".format(self.tunnel_id))
412
+ print("🎉 SUCCESS! Persistent URLs created:")
413
+ print("📔 Jupyter: {}".format(self.jupyter_url))
414
+ print("🔧 API: {}".format(self.api_expose_url))
415
+ print("🔐 SSH: {}".format(self.ssh_url))
416
+ print("")
417
+ print("SSH Connection Command:")
418
+ import getpass
419
+ current_user = getpass.getuser()
420
+ print("ssh -o ProxyCommand='cloudflared access ssh --hostname {}' {}@{}".format(
421
+ self.ssh_url, current_user, self.ssh_url))
422
+ print("")
423
+ print("Tunnel ID: {}".format(self.tunnel_id))
334
424
  print("="*50)
425
+
335
426
 
336
427
  return True
337
428
 
unitlab/utils.py CHANGED
@@ -66,3 +66,5 @@ def get_api_url() -> str:
66
66
  except Exception:
67
67
  return "https://api.unitlab.ai"
68
68
 
69
+
70
+