Browse Source

Implement SSH/Dashboard tunnel management system

Backend:
- Add TunnelService for session management with in-memory storage
- Add /api/v1/superadmin/tunnels endpoints:
  - POST /devices/{id}/{type} - Enable tunnel, create session
  - GET /sessions/{uuid}/status - Poll tunnel status
  - POST /sessions/{uuid}/heartbeat - Keep session alive
- Add /api/v1/tunnel-port for devices to report allocated ports
- Add background cleanup task (60 min inactivity, 1 hour TTL)
- Support for ttyd process spawning when tunnel ready

Frontend:
- Add tunnels.js API client
- Update DevicesView tunnel buttons:
  - Remove disabled state (always active)
  - Add spinner animation while connecting
  - Poll backend every 1 second for tunnel status
  - Open tunnel URL in new tab when ready
  - 60 second timeout with error handling

Workflow:
1. Admin clicks SSH/Dashboard button
2. Backend creates session, enables tunnel in device config
3. Device polls config (≤30 sec), starts reverse SSH tunnel
4. Device reports allocated port to /api/v1/tunnel-port
5. Frontend polls status until ready
6. Backend spawns ttyd, returns tunnel URL
7. Frontend opens URL in new browser tab

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 1 month ago
parent
commit
2d6417a3df

+ 2 - 1
backend/app/api/v1/router.py

@@ -4,7 +4,7 @@ Main API v1 router - aggregates all v1 endpoints.
 
 from fastapi import APIRouter
 
-from app.api.v1 import auth, client, config, events, registration, superadmin
+from app.api.v1 import auth, client, config, events, registration, superadmin, tunnel_port
 
 # Create main v1 router
 router = APIRouter()
@@ -18,3 +18,4 @@ router.include_router(client.router, prefix="/client")
 router.include_router(registration.router, tags=["device-api"])
 router.include_router(config.router, tags=["device-api"])
 router.include_router(events.router, tags=["device-api"])
+router.include_router(tunnel_port.router, tags=["device-api"])

+ 2 - 1
backend/app/api/v1/superadmin/__init__.py

@@ -4,7 +4,7 @@ Superadmin API endpoints.
 
 from fastapi import APIRouter
 
-from app.api.v1.superadmin import devices, organizations, settings, users
+from app.api.v1.superadmin import devices, organizations, settings, tunnels, users
 
 router = APIRouter()
 
@@ -12,3 +12,4 @@ router.include_router(organizations.router, prefix="/organizations", tags=["supe
 router.include_router(users.router, prefix="/users", tags=["superadmin-users"])
 router.include_router(devices.router, prefix="/devices", tags=["superadmin-devices"])
 router.include_router(settings.router, prefix="/settings", tags=["superadmin-settings"])
+router.include_router(tunnels.router)

+ 173 - 0
backend/app/api/v1/superadmin/tunnels.py

@@ -0,0 +1,173 @@
+"""
+Superadmin tunnel management API endpoints.
+"""
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.ext.asyncio import AsyncSession
+from pydantic import BaseModel
+
+from app.core.database import get_db
+from app.core.permissions import require_permission
+from app.models.device import Device
+from app.models.user import User
+from app.services.tunnel_service import tunnel_service
+from sqlalchemy import select
+
+
+router = APIRouter(prefix="/tunnels", tags=["superadmin-tunnels"])
+
+
+class TunnelEnableResponse(BaseModel):
+    """Response when enabling tunnel"""
+    session_uuid: str
+    device_id: str
+    tunnel_type: str
+    status: str
+
+
+class TunnelStatusResponse(BaseModel):
+    """Tunnel session status response"""
+    session_uuid: str
+    status: str  # "waiting" | "ready" | "failed"
+    device_tunnel_port: Optional[int] = None
+    ttyd_port: Optional[int] = None
+    tunnel_url: Optional[str] = None
+
+
+@router.post("/devices/{device_id}/{tunnel_type}")
+@require_permission("devices", "manage")
+async def enable_tunnel(
+    device_id: int,
+    tunnel_type: str,
+    current_user: User = Depends(require_permission("devices", "manage")),
+    db: AsyncSession = Depends(get_db)
+) -> TunnelEnableResponse:
+    """
+    Enable SSH or Dashboard tunnel for device.
+    Creates session and triggers device to connect tunnel.
+
+    Args:
+        device_id: Device numeric ID
+        tunnel_type: "ssh" or "dashboard"
+
+    Returns:
+        session_uuid: UUID for polling status
+    """
+    if tunnel_type not in ["ssh", "dashboard"]:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="tunnel_type must be 'ssh' or 'dashboard'"
+        )
+
+    # Get device
+    result = await db.execute(
+        select(Device).where(Device.id == device_id)
+    )
+    device = result.scalar_one_or_none()
+
+    if not device:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Device not found"
+        )
+
+    # Create tunnel session
+    session = tunnel_service.create_session(
+        device_id=device.mac_address,
+        admin_user=current_user.email,
+        tunnel_type=tunnel_type
+    )
+
+    # Update device config to enable tunnel
+    if device.config is None:
+        device.config = {}
+
+    tunnel_key = f"{tunnel_type}_tunnel"
+    if tunnel_key not in device.config:
+        device.config[tunnel_key] = {}
+
+    device.config[tunnel_key]["enabled"] = True
+
+    # Mark as modified (SQLAlchemy JSON field)
+    from sqlalchemy.orm import attributes
+    attributes.flag_modified(device, "config")
+
+    await db.commit()
+
+    return TunnelEnableResponse(
+        session_uuid=session.uuid,
+        device_id=device.mac_address,
+        tunnel_type=tunnel_type,
+        status="waiting"
+    )
+
+
+@router.get("/sessions/{session_uuid}/status")
+async def get_tunnel_status(
+    session_uuid: str,
+    current_user: User = Depends(require_permission("devices", "view"))
+) -> TunnelStatusResponse:
+    """
+    Poll tunnel session status.
+
+    Returns:
+        - waiting: Device not yet connected
+        - ready: Tunnel connected, ttyd spawned
+        - failed: Session expired or failed
+    """
+    session = tunnel_service.get_session(session_uuid)
+
+    if not session:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Session not found or expired"
+        )
+
+    # Build response
+    response = TunnelStatusResponse(
+        session_uuid=session.uuid,
+        status=session.status,
+        device_tunnel_port=session.device_tunnel_port,
+        ttyd_port=session.ttyd_port
+    )
+
+    # If ready, spawn ttyd if not already spawned
+    if session.status == "ready" and session.device_tunnel_port:
+        if not session.ttyd_port:
+            try:
+                # Spawn ttyd
+                ttyd_port = tunnel_service.spawn_ttyd(
+                    session_uuid=session.uuid,
+                    device_tunnel_port=session.device_tunnel_port
+                )
+                response.ttyd_port = ttyd_port
+                response.tunnel_url = f"/admin/{session.tunnel_type}/{session.uuid}"
+            except Exception as e:
+                print(f"[tunnel] Failed to spawn ttyd: {e}")
+                session.status = "failed"
+                response.status = "failed"
+        else:
+            response.tunnel_url = f"/admin/{session.tunnel_type}/{session.uuid}"
+
+    return response
+
+
+@router.post("/sessions/{session_uuid}/heartbeat")
+async def session_heartbeat(
+    session_uuid: str,
+    current_user: User = Depends(require_permission("devices", "view"))
+):
+    """
+    Browser sends heartbeat every 30 seconds to keep session alive.
+    """
+    success = tunnel_service.update_heartbeat(session_uuid)
+
+    if not success:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Session not found"
+        )
+
+    return {"success": True}

+ 87 - 0
backend/app/api/v1/tunnel_port.py

@@ -0,0 +1,87 @@
+"""
+Device API endpoint for reporting tunnel port allocation.
+"""
+
+from typing import Annotated, Optional
+
+from fastapi import APIRouter, Depends, Header, HTTPException, status
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.models.device import Device
+from app.services.tunnel_service import tunnel_service
+
+
+router = APIRouter(tags=["device-api"])
+
+
+class TunnelPortRequest(BaseModel):
+    """Device reports tunnel port"""
+    tunnel_type: str  # "ssh" | "dashboard"
+    port: Optional[int] = None
+    status: str  # "connected" | "disconnected"
+
+
+async def _auth_device_token(
+    authorization: str | None, db: AsyncSession
+) -> Device:
+    """Authenticate device by token from Authorization header."""
+    if not authorization or not authorization.lower().startswith("bearer "):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Missing token",
+        )
+
+    token = authorization.split(None, 1)[1]
+
+    result = await db.execute(select(Device).where(Device.device_token == token))
+    device = result.scalar_one_or_none()
+
+    if not device:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Invalid token",
+        )
+
+    return device
+
+
+@router.post("/tunnel-port")
+async def report_tunnel_port(
+    req: TunnelPortRequest,
+    db: Annotated[AsyncSession, Depends(get_db)],
+    authorization: Annotated[str | None, Header()] = None,
+):
+    """
+    Device reports tunnel port allocation after establishing reverse SSH tunnel.
+
+    Args:
+        tunnel_type: "ssh" or "dashboard"
+        port: Allocated port number (if connected)
+        status: "connected" or "disconnected"
+
+    Returns:
+        success: True
+    """
+    device = await _auth_device_token(authorization, db)
+
+    if req.tunnel_type not in ["ssh", "dashboard"]:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="tunnel_type must be 'ssh' or 'dashboard'"
+        )
+
+    # Update tunnel status
+    tunnel_service.report_device_port(
+        device_id=device.mac_address,
+        tunnel_type=req.tunnel_type,
+        port=req.port,
+        status=req.status
+    )
+
+    print(f"[tunnel] Device {device.mac_address} reported {req.tunnel_type} "
+          f"tunnel {req.status}" + (f" on port {req.port}" if req.port else ""))
+
+    return {"success": True}

+ 10 - 0
backend/app/main.py

@@ -46,3 +46,13 @@ async def health_check():
 from app.api.v1 import router as api_v1_router
 
 app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX)
+
+
+# Startup event
+@app.on_event("startup")
+async def startup_event():
+    """Initialize services on startup"""
+    # Start tunnel cleanup background task
+    from app.services.tunnel_service import tunnel_service
+    tunnel_service.start_background_cleanup()
+    print("[startup] Tunnel cleanup task started")

+ 258 - 0
backend/app/services/tunnel_service.py

@@ -0,0 +1,258 @@
+"""
+Tunnel session management service for SSH and Dashboard tunnels.
+"""
+
+import asyncio
+import os
+import signal
+import subprocess
+import uuid as uuid_module
+from datetime import datetime, timedelta
+from typing import Dict, Optional
+
+from pydantic import BaseModel
+
+
+class TunnelSession(BaseModel):
+    """Tunnel session model"""
+    uuid: str
+    device_id: str
+    admin_user: str
+    tunnel_type: str  # "ssh" | "dashboard"
+    created_at: datetime
+    expires_at: datetime
+    last_heartbeat: Optional[datetime] = None
+    ttyd_port: Optional[int] = None
+    ttyd_pid: Optional[int] = None
+    device_tunnel_port: Optional[int] = None
+    status: str = "waiting"  # "waiting" | "ready" | "failed"
+
+
+class TunnelStatus(BaseModel):
+    """Device tunnel status"""
+    device_id: str
+    tunnel_type: str  # "ssh" | "dashboard"
+    allocated_port: Optional[int] = None
+    status: str  # "connected" | "disconnected"
+    connected_at: Optional[datetime] = None
+    last_heartbeat: Optional[datetime] = None
+
+
+class TunnelService:
+    """
+    Tunnel management service
+    In-memory storage (можно заменить на Redis для multi-server)
+    """
+
+    def __init__(self):
+        self.sessions: Dict[str, TunnelSession] = {}
+        self.tunnel_status: Dict[str, TunnelStatus] = {}
+        self.cleanup_task = None
+
+    def start_background_cleanup(self):
+        """Start background task for cleanup inactive sessions"""
+        if not self.cleanup_task:
+            self.cleanup_task = asyncio.create_task(self._cleanup_loop())
+
+    async def _cleanup_loop(self):
+        """Background cleanup loop"""
+        while True:
+            await asyncio.sleep(300)  # Every 5 minutes
+            await self.cleanup_inactive_sessions()
+
+    async def cleanup_inactive_sessions(self):
+        """
+        Kill ttyd processes with no heartbeat for 60 minutes
+        Remove expired sessions
+        """
+        now = datetime.now()
+        inactive_threshold = now - timedelta(minutes=60)
+        grace_period = now - timedelta(seconds=60)
+
+        for session_uuid, session in list(self.sessions.items()):
+            # Check expiration (hard limit: 1 hour)
+            if now > session.expires_at:
+                print(f"[tunnel] Session expired: {session_uuid}")
+                self._kill_ttyd(session.ttyd_pid)
+                del self.sessions[session_uuid]
+                continue
+
+            # Check inactivity (60 minutes without heartbeat)
+            if session.last_heartbeat and session.last_heartbeat < inactive_threshold:
+                print(f"[tunnel] Session inactive for 60 min: {session_uuid}")
+                self._kill_ttyd(session.ttyd_pid)
+                del self.sessions[session_uuid]
+                continue
+
+            # Grace period: if tab closed, wait 60 seconds before killing
+            if session.last_heartbeat and session.last_heartbeat < grace_period:
+                if session.ttyd_pid and not self._is_process_alive(session.ttyd_pid):
+                    print(f"[tunnel] ttyd process dead: {session_uuid}")
+                    del self.sessions[session_uuid]
+
+    def create_session(
+        self,
+        device_id: str,
+        admin_user: str,
+        tunnel_type: str
+    ) -> TunnelSession:
+        """Create new tunnel session"""
+        session_uuid = str(uuid_module.uuid4())
+        now = datetime.now()
+
+        session = TunnelSession(
+            uuid=session_uuid,
+            device_id=device_id,
+            admin_user=admin_user,
+            tunnel_type=tunnel_type,
+            created_at=now,
+            expires_at=now + timedelta(hours=1),
+            status="waiting"
+        )
+
+        self.sessions[session_uuid] = session
+
+        # Create tunnel status key
+        status_key = f"{device_id}:{tunnel_type}"
+        if status_key not in self.tunnel_status:
+            self.tunnel_status[status_key] = TunnelStatus(
+                device_id=device_id,
+                tunnel_type=tunnel_type,
+                status="disconnected"
+            )
+
+        return session
+
+    def get_session(self, session_uuid: str) -> Optional[TunnelSession]:
+        """Get session by UUID"""
+        return self.sessions.get(session_uuid)
+
+    def update_heartbeat(self, session_uuid: str) -> bool:
+        """Update session heartbeat"""
+        session = self.sessions.get(session_uuid)
+        if not session:
+            return False
+
+        session.last_heartbeat = datetime.now()
+        return True
+
+    def report_device_port(
+        self,
+        device_id: str,
+        tunnel_type: str,
+        port: Optional[int],
+        status: str
+    ):
+        """Device reports tunnel port allocation"""
+        status_key = f"{device_id}:{tunnel_type}"
+
+        if status == "connected" and port:
+            self.tunnel_status[status_key] = TunnelStatus(
+                device_id=device_id,
+                tunnel_type=tunnel_type,
+                allocated_port=port,
+                status="connected",
+                connected_at=datetime.now(),
+                last_heartbeat=datetime.now()
+            )
+
+            # Update all waiting sessions for this device
+            for session in self.sessions.values():
+                if (session.device_id == device_id and
+                    session.tunnel_type == tunnel_type and
+                    session.status == "waiting"):
+                    session.device_tunnel_port = port
+                    session.status = "ready"
+
+        elif status == "disconnected":
+            if status_key in self.tunnel_status:
+                self.tunnel_status[status_key].status = "disconnected"
+                self.tunnel_status[status_key].allocated_port = None
+
+    def get_tunnel_status(
+        self,
+        device_id: str,
+        tunnel_type: str
+    ) -> Optional[TunnelStatus]:
+        """Get tunnel status for device"""
+        status_key = f"{device_id}:{tunnel_type}"
+        return self.tunnel_status.get(status_key)
+
+    def spawn_ttyd(
+        self,
+        session_uuid: str,
+        device_tunnel_port: int,
+        server_host: str = "localhost"
+    ) -> int:
+        """
+        Spawn ttyd process for terminal access
+        Returns ttyd port
+        """
+        session = self.sessions.get(session_uuid)
+        if not session:
+            raise ValueError(f"Session not found: {session_uuid}")
+
+        # Find free port for ttyd (45000-49999)
+        ttyd_port = self._find_free_port(45000, 49999)
+
+        # Spawn ttyd process
+        # ttyd connects to device via SSH through the tunnel port
+        cmd = [
+            "ttyd",
+            "--port", str(ttyd_port),
+            "--once",  # Single session
+            "--writable",  # Allow input
+            "ssh",
+            "-p", str(device_tunnel_port),
+            "-o", "StrictHostKeyChecking=no",
+            "-o", "UserKnownHostsFile=/dev/null",
+            f"root@{server_host}"
+        ]
+
+        process = subprocess.Popen(
+            cmd,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL
+        )
+
+        session.ttyd_port = ttyd_port
+        session.ttyd_pid = process.pid
+
+        print(f"[tunnel] Spawned ttyd on port {ttyd_port} (pid={process.pid})")
+
+        return ttyd_port
+
+    def _find_free_port(self, start: int, end: int) -> int:
+        """Find free port in range"""
+        import socket
+        for port in range(start, end + 1):
+            try:
+                with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                    s.bind(('', port))
+                    return port
+            except OSError:
+                continue
+        raise RuntimeError(f"No free ports in range {start}-{end}")
+
+    def _kill_ttyd(self, pid: Optional[int]):
+        """Kill ttyd process gracefully"""
+        if not pid:
+            return
+
+        try:
+            os.kill(pid, signal.SIGTERM)
+            print(f"[tunnel] Killed ttyd process {pid}")
+        except ProcessLookupError:
+            pass
+
+    def _is_process_alive(self, pid: int) -> bool:
+        """Check if process is running"""
+        try:
+            os.kill(pid, 0)  # Signal 0 = check existence
+            return True
+        except ProcessLookupError:
+            return False
+
+
+# Global tunnel service instance
+tunnel_service = TunnelService()

+ 46 - 0
frontend/src/api/tunnels.js

@@ -0,0 +1,46 @@
+/**
+ * Tunnels API client for SSH and Dashboard tunnel management.
+ */
+
+import client from './client'
+
+const tunnelsApi = {
+  /**
+   * Enable tunnel for device and create session
+   * @param {number} deviceId - Device numeric ID
+   * @param {string} tunnelType - "ssh" or "dashboard"
+   * @returns {Promise<{session_uuid: string, device_id: string, tunnel_type: string, status: string}>}
+   */
+  async enableTunnel(deviceId, tunnelType) {
+    const { data } = await client.post(
+      `/superadmin/tunnels/devices/${deviceId}/${tunnelType}`
+    )
+    return data
+  },
+
+  /**
+   * Get tunnel session status
+   * @param {string} sessionUuid - Session UUID
+   * @returns {Promise<{session_uuid: string, status: string, device_tunnel_port?: number, ttyd_port?: number, tunnel_url?: string}>}
+   */
+  async getSessionStatus(sessionUuid) {
+    const { data } = await client.get(
+      `/superadmin/tunnels/sessions/${sessionUuid}/status`
+    )
+    return data
+  },
+
+  /**
+   * Send heartbeat to keep session alive
+   * @param {string} sessionUuid - Session UUID
+   * @returns {Promise<{success: boolean}>}
+   */
+  async sendHeartbeat(sessionUuid) {
+    const { data } = await client.post(
+      `/superadmin/tunnels/sessions/${sessionUuid}/heartbeat`
+    )
+    return data
+  }
+}
+
+export default tunnelsApi

+ 63 - 31
frontend/src/views/superadmin/DevicesView.vue

@@ -64,18 +64,20 @@
               <button
                 @click="openSSH(device)"
                 class="btn-icon"
-                :class="{ disabled: !isTunnelAvailable(device, 'ssh') }"
-                :disabled="!isTunnelAvailable(device, 'ssh')"
-                title="SSH Terminal">
-                🖥️
+                :class="{ loading: tunnelLoading[`${device.id}:ssh`] }"
+                :disabled="tunnelLoading[`${device.id}:ssh`]"
+                :title="tunnelLoading[`${device.id}:ssh`] ? 'Connecting...' : 'SSH Terminal'">
+                <span v-if="tunnelLoading[`${device.id}:ssh`]" class="spinner">⏳</span>
+                <span v-else>🖥️</span>
               </button>
               <button
                 @click="openDashboard(device)"
                 class="btn-icon"
-                :class="{ disabled: !isTunnelAvailable(device, 'dashboard') }"
-                :disabled="!isTunnelAvailable(device, 'dashboard')"
-                title="Dashboard">
-                📊
+                :class="{ loading: tunnelLoading[`${device.id}:dashboard`] }"
+                :disabled="tunnelLoading[`${device.id}:dashboard`]"
+                :title="tunnelLoading[`${device.id}:dashboard`] ? 'Connecting...' : 'Dashboard'">
+                <span v-if="tunnelLoading[`${device.id}:dashboard`]" class="spinner">⏳</span>
+                <span v-else>📊</span>
               </button>
             </td>
           </tr>
@@ -216,6 +218,7 @@
 import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
 import devicesApi from '@/api/devices'
 import organizationsApi from '@/api/organizations'
+import tunnelsApi from '@/api/tunnels'
 
 const devices = ref([])
 const organizations = ref([])
@@ -229,6 +232,7 @@ const onlineOnly = ref(false)
 const sortColumn = ref('simple_id')
 const sortDirection = ref('desc')
 const ntpServersText = ref('')
+const tunnelLoading = ref({})
 let searchDebounceTimer = null
 let pollingInterval = null
 
@@ -412,33 +416,61 @@ async function saveDevice() {
   }
 }
 
-function isTunnelAvailable(device, type) {
-  if (!device.config) return false
+async function openTunnel(device, tunnelType) {
+  const loadingKey = `${device.id}:${tunnelType}`
+  tunnelLoading.value[loadingKey] = true
 
-  if (type === 'ssh') {
-    return device.config.ssh_tunnel?.enabled && device.config.ssh_tunnel?.remote_port > 0
-  } else if (type === 'dashboard') {
-    return device.config.dashboard_tunnel?.enabled && device.config.dashboard_tunnel?.remote_port > 0
+  try {
+    // Step 1: Enable tunnel, get session UUID
+    const { session_uuid } = await tunnelsApi.enableTunnel(device.id, tunnelType)
+
+    // Step 2: Poll for tunnel status
+    const maxAttempts = 60 // 60 seconds max wait
+    let attempts = 0
+
+    const pollInterval = setInterval(async () => {
+      attempts++
+
+      try {
+        const status = await tunnelsApi.getSessionStatus(session_uuid)
+
+        if (status.status === 'ready' && status.tunnel_url) {
+          // Clear polling
+          clearInterval(pollInterval)
+          tunnelLoading.value[loadingKey] = false
+
+          // Open tunnel URL in new tab
+          window.open(status.tunnel_url, '_blank')
+        } else if (status.status === 'failed') {
+          clearInterval(pollInterval)
+          tunnelLoading.value[loadingKey] = false
+          alert('Failed to establish tunnel')
+        } else if (attempts >= maxAttempts) {
+          clearInterval(pollInterval)
+          tunnelLoading.value[loadingKey] = false
+          alert('Tunnel connection timeout')
+        }
+      } catch (err) {
+        clearInterval(pollInterval)
+        tunnelLoading.value[loadingKey] = false
+        console.error('Failed to poll tunnel status:', err)
+        alert('Failed to check tunnel status')
+      }
+    }, 1000) // Poll every 1 second
+
+  } catch (err) {
+    tunnelLoading.value[loadingKey] = false
+    console.error('Failed to enable tunnel:', err)
+    alert(err.response?.data?.detail || 'Failed to enable tunnel')
   }
-  return false
 }
 
 function openSSH(device) {
-  if (!isTunnelAvailable(device, 'ssh')) return
-
-  const server = device.config.ssh_tunnel.server
-  const port = device.config.ssh_tunnel.remote_port
-  const url = `http://${server}:${port}`
-  window.open(url, '_blank')
+  openTunnel(device, 'ssh')
 }
 
 function openDashboard(device) {
-  if (!isTunnelAvailable(device, 'dashboard')) return
-
-  const server = device.config.dashboard_tunnel.server
-  const port = device.config.dashboard_tunnel.remote_port
-  const url = `http://${server}:${port}`
-  window.open(url, '_blank')
+  openTunnel(device, 'dashboard')
 }
 
 onMounted(() => {
@@ -495,10 +527,10 @@ code { background: #f7fafc; padding: 4px 8px; border-radius: 4px; font-family: m
 .badge.status-error { background: #fed7d7; color: #742a2a; }
 .btn-icon { padding: 6px 10px; background: none; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: opacity 0.2s; }
 .btn-icon:hover { opacity: 1; background: #f7fafc; border-radius: 4px; }
-.btn-icon:disabled,
-.btn-icon.disabled { opacity: 0.3; cursor: not-allowed; }
-.btn-icon:disabled:hover,
-.btn-icon.disabled:hover { background: none; }
+.btn-icon:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn-icon:disabled:hover { background: none; }
+.btn-icon.loading .spinner { display: inline-block; animation: spin 1s linear infinite; }
+@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 .btn-primary { padding: 12px 24px; background: #667eea; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
 .btn-primary:hover { background: #5568d3; }
 .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }