Please provide the specific file changes or a description of the modifications you have made so I can generate the commit message for you.
This commit is contained in:
+12
-13
@@ -1,24 +1,23 @@
|
|||||||
# Backend
|
# Backend
|
||||||
backend/target/
|
backend/target/
|
||||||
backend/data/*.db
|
backend/data/*.db
|
||||||
backend/data/*.mv.db
|
backend/data/*.mv.db
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
# Logs & IDE
|
# Logs & IDE
|
||||||
*.log
|
*.log
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.local
|
*.local
|
||||||
.codex
|
|
||||||
/package-lock.json
|
/package-lock.json
|
||||||
release/
|
release/
|
||||||
.opencode/
|
.opencode/
|
||||||
|
|
||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
# Keep frontend .gitignore for frontend-specific rules
|
# Keep frontend .gitignore for frontend-specific rules
|
||||||
!frontend/.gitignore
|
!frontend/.gitignore
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import com.sshmanager.dto.BackupImportResponseDto;
|
|||||||
import com.sshmanager.dto.BackupPackageDto;
|
import com.sshmanager.dto.BackupPackageDto;
|
||||||
import com.sshmanager.dto.BatchCommandRequest;
|
import com.sshmanager.dto.BatchCommandRequest;
|
||||||
import com.sshmanager.dto.BatchCommandResponseDto;
|
import com.sshmanager.dto.BatchCommandResponseDto;
|
||||||
|
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
||||||
|
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
import com.sshmanager.entity.User;
|
import com.sshmanager.entity.User;
|
||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.BackupService;
|
import com.sshmanager.service.BackupService;
|
||||||
import com.sshmanager.service.BatchCommandService;
|
import com.sshmanager.service.BatchCommandService;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
|
import com.sshmanager.service.ConnectionStatusService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -27,15 +30,18 @@ public class ConnectionController {
|
|||||||
private final ConnectionService connectionService;
|
private final ConnectionService connectionService;
|
||||||
private final BackupService backupService;
|
private final BackupService backupService;
|
||||||
private final BatchCommandService batchCommandService;
|
private final BatchCommandService batchCommandService;
|
||||||
|
private final ConnectionStatusService connectionStatusService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public ConnectionController(ConnectionService connectionService,
|
public ConnectionController(ConnectionService connectionService,
|
||||||
BackupService backupService,
|
BackupService backupService,
|
||||||
BatchCommandService batchCommandService,
|
BatchCommandService batchCommandService,
|
||||||
|
ConnectionStatusService connectionStatusService,
|
||||||
UserRepository userRepository) {
|
UserRepository userRepository) {
|
||||||
this.connectionService = connectionService;
|
this.connectionService = connectionService;
|
||||||
this.backupService = backupService;
|
this.backupService = backupService;
|
||||||
this.batchCommandService = batchCommandService;
|
this.batchCommandService = batchCommandService;
|
||||||
|
this.connectionStatusService = connectionStatusService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +107,16 @@ public class ConnectionController {
|
|||||||
return ResponseEntity.ok(batchCommandService.execute(userId, request));
|
return ResponseEntity.ok(batchCommandService.execute(userId, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/status")
|
||||||
|
public ResponseEntity<ConnectionStatusResponseDto> checkStatuses(@RequestBody ConnectionStatusCheckRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
return ResponseEntity.ok(connectionStatusService.checkStatuses(userId, request));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/test")
|
@PostMapping("/test")
|
||||||
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
|
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
Connection fullConn = connectionService.getConnectionForSsh(connection.getId(), userId);
|
Connection fullConn = connectionService.getConnectionForSsh(connection.getId(), userId);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.sshmanager.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ConnectionStatusCheckRequest {
|
||||||
|
|
||||||
|
private List<Long> connectionIds = new ArrayList<Long>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.sshmanager.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ConnectionStatusItemDto {
|
||||||
|
|
||||||
|
private Long connectionId;
|
||||||
|
private String connectionName;
|
||||||
|
private String status;
|
||||||
|
private String message;
|
||||||
|
private long durationMs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.sshmanager.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ConnectionStatusResponseDto {
|
||||||
|
|
||||||
|
private int total;
|
||||||
|
private int onlineCount;
|
||||||
|
private int offlineCount;
|
||||||
|
private List<ConnectionStatusItemDto> results = new ArrayList<ConnectionStatusItemDto>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
||||||
|
import com.sshmanager.dto.ConnectionStatusItemDto;
|
||||||
|
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ConnectionStatusService {
|
||||||
|
|
||||||
|
private final ConnectionService connectionService;
|
||||||
|
|
||||||
|
public ConnectionStatusService(ConnectionService connectionService) {
|
||||||
|
this.connectionService = connectionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionStatusResponseDto checkStatuses(Long userId, ConnectionStatusCheckRequest request) {
|
||||||
|
if (request == null || request.getConnectionIds() == null || request.getConnectionIds().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("At least one connection is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ConnectionStatusItemDto> results = new ArrayList<ConnectionStatusItemDto>();
|
||||||
|
int onlineCount = 0;
|
||||||
|
int offlineCount = 0;
|
||||||
|
|
||||||
|
for (Long connectionId : request.getConnectionIds()) {
|
||||||
|
Connection connection = connectionService.getConnectionForSsh(connectionId, userId);
|
||||||
|
long startedAt = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
connectionService.testConnection(
|
||||||
|
connection,
|
||||||
|
connectionService.getDecryptedPassword(connection),
|
||||||
|
connectionService.getDecryptedPrivateKey(connection),
|
||||||
|
connectionService.getDecryptedPassphrase(connection)
|
||||||
|
);
|
||||||
|
long durationMs = System.currentTimeMillis() - startedAt;
|
||||||
|
results.add(new ConnectionStatusItemDto(
|
||||||
|
connection.getId(),
|
||||||
|
connection.getName(),
|
||||||
|
"online",
|
||||||
|
"SSH connection available",
|
||||||
|
durationMs
|
||||||
|
));
|
||||||
|
onlineCount += 1;
|
||||||
|
} catch (Exception error) {
|
||||||
|
long durationMs = System.currentTimeMillis() - startedAt;
|
||||||
|
results.add(new ConnectionStatusItemDto(
|
||||||
|
connection.getId(),
|
||||||
|
connection.getName(),
|
||||||
|
"offline",
|
||||||
|
error.getMessage(),
|
||||||
|
durationMs
|
||||||
|
));
|
||||||
|
offlineCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionStatusResponseDto response = new ConnectionStatusResponseDto();
|
||||||
|
response.setTotal(results.size());
|
||||||
|
response.setOnlineCount(onlineCount);
|
||||||
|
response.setOfflineCount(offlineCount);
|
||||||
|
response.setResults(results);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,13 @@ import com.sshmanager.dto.BackupImportResponseDto;
|
|||||||
import com.sshmanager.dto.BackupPackageDto;
|
import com.sshmanager.dto.BackupPackageDto;
|
||||||
import com.sshmanager.dto.BatchCommandRequest;
|
import com.sshmanager.dto.BatchCommandRequest;
|
||||||
import com.sshmanager.dto.BatchCommandResponseDto;
|
import com.sshmanager.dto.BatchCommandResponseDto;
|
||||||
|
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
||||||
|
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.BackupService;
|
import com.sshmanager.service.BackupService;
|
||||||
import com.sshmanager.service.BatchCommandService;
|
import com.sshmanager.service.BatchCommandService;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
|
import com.sshmanager.service.ConnectionStatusService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -47,6 +50,9 @@ class ConnectionControllerTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private BatchCommandService batchCommandService;
|
private BatchCommandService batchCommandService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ConnectionStatusService connectionStatusService;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
@@ -149,4 +155,18 @@ class ConnectionControllerTest {
|
|||||||
assertEquals(expected, response.getBody());
|
assertEquals(expected, response.getBody());
|
||||||
verify(batchCommandService).execute(1L, request);
|
verify(batchCommandService).execute(1L, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkStatusesUsesCurrentUserId() {
|
||||||
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
|
ConnectionStatusResponseDto expected = new ConnectionStatusResponseDto();
|
||||||
|
expected.setTotal(2);
|
||||||
|
when(connectionStatusService.checkStatuses(1L, request)).thenReturn(expected);
|
||||||
|
|
||||||
|
ResponseEntity<ConnectionStatusResponseDto> response = connectionController.checkStatuses(request, authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
assertEquals(expected, response.getBody());
|
||||||
|
verify(connectionStatusService).checkStatuses(1L, request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
||||||
|
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ConnectionStatusServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ConnectionService connectionService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ConnectionStatusService connectionStatusService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkStatusesAggregatesOnlineAndOfflineResults() {
|
||||||
|
Connection onlineConnection = new Connection();
|
||||||
|
onlineConnection.setId(1L);
|
||||||
|
onlineConnection.setUserId(99L);
|
||||||
|
onlineConnection.setName("prod");
|
||||||
|
|
||||||
|
Connection offlineConnection = new Connection();
|
||||||
|
offlineConnection.setId(2L);
|
||||||
|
offlineConnection.setUserId(99L);
|
||||||
|
offlineConnection.setName("test");
|
||||||
|
|
||||||
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
|
request.setConnectionIds(Arrays.asList(1L, 2L));
|
||||||
|
|
||||||
|
when(connectionService.getConnectionForSsh(1L, 99L)).thenReturn(onlineConnection);
|
||||||
|
when(connectionService.getConnectionForSsh(2L, 99L)).thenReturn(offlineConnection);
|
||||||
|
doReturn(onlineConnection).when(connectionService).testConnection(eq(onlineConnection), eq(null), eq(null), eq(null));
|
||||||
|
doThrow(new RuntimeException("Connection refused")).when(connectionService).testConnection(eq(offlineConnection), eq(null), eq(null), eq(null));
|
||||||
|
|
||||||
|
ConnectionStatusResponseDto response = connectionStatusService.checkStatuses(99L, request);
|
||||||
|
|
||||||
|
assertEquals(2, response.getTotal());
|
||||||
|
assertEquals(1, response.getOnlineCount());
|
||||||
|
assertEquals(1, response.getOfflineCount());
|
||||||
|
assertEquals("online", response.getResults().get(0).getStatus());
|
||||||
|
assertEquals("offline", response.getResults().get(1).getStatus());
|
||||||
|
assertEquals("prod", response.getResults().get(0).getConnectionName());
|
||||||
|
assertEquals("Connection refused", response.getResults().get(1).getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkStatusesRejectsEmptyConnectionIds() {
|
||||||
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
|
|
||||||
|
IllegalArgumentException error = assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> connectionStatusService.checkStatuses(1L, request)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("At least one connection is required", error.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +1,38 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="SSH Manager">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||||
|
<!-- 背景层:深色圆角矩形与发光效果 -->
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#07152f" />
|
<stop offset="0%" stop-color="#0f172a" />
|
||||||
<stop offset="100%" stop-color="#0a1d3f" />
|
<stop offset="100%" stop-color="#020617" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="neon" x1="0%" y1="0%" x2="100%" y2="100%">
|
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
<stop offset="0%" stop-color="#3cc8ff" />
|
<feGaussianBlur stdDeviation="15" result="blur" />
|
||||||
<stop offset="55%" stop-color="#6ce7ff" />
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
<stop offset="100%" stop-color="#7fffd4" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="panel" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#17345f" />
|
|
||||||
<stop offset="100%" stop-color="#112748" />
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
</filter>
|
||||||
<filter id="softGlow" x="-50%" y="-50%" width="200%" height="200%">
|
<linearGradient id="primaryGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<feGaussianBlur stdDeviation="2.4" result="blur" />
|
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.6" />
|
||||||
<feMerge>
|
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3" />
|
||||||
<feMergeNode in="blur" />
|
</linearGradient>
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
<style>
|
|
||||||
.trace { fill: none; stroke: #143b6f; stroke-linecap: round; stroke-width: 2.2; opacity: 0.75; }
|
|
||||||
.trace2 { fill: none; stroke: #1a4f88; stroke-linecap: round; stroke-width: 1.6; opacity: 0.6; }
|
|
||||||
.dot { fill: #1f78b4; opacity: 0.85; }
|
|
||||||
.rack { fill: #17345f; stroke: url(#neon); stroke-width: 2.2; }
|
|
||||||
.lightG { fill: #88ff96; }
|
|
||||||
.lightY { fill: #ffd96b; }
|
|
||||||
.lightR { fill: #ff7686; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<rect x="18" y="18" width="220" height="220" rx="34" fill="url(#bg)" />
|
<!-- 外部圆角底框 -->
|
||||||
<rect x="18" y="18" width="220" height="220" rx="34" fill="none" stroke="#3cc8ff" stroke-width="4" filter="url(#glow)" />
|
<rect x="32" y="32" width="448" height="448" rx="100" ry="100" fill="url(#bgGradient)" stroke="#1e293b" stroke-width="8" />
|
||||||
|
|
||||||
<path class="trace" d="M36 62h28v-18h18v34h20" />
|
<!-- 内部光晕点缀 -->
|
||||||
<path class="trace" d="M36 96h42v18h22v-16h24" />
|
<circle cx="256" cy="256" r="180" fill="url(#primaryGlow)" filter="url(#glow)" />
|
||||||
<path class="trace" d="M36 160h24v22h34v-18h18" />
|
<rect x="64" y="64" width="384" height="384" rx="80" ry="80" fill="#0f172a" opacity="0.85" />
|
||||||
<path class="trace" d="M36 196h54v-26h20" />
|
|
||||||
<path class="trace" d="M220 58h-24v-14h-22v36h-14" />
|
|
||||||
<path class="trace" d="M220 98h-38v14h-20v-18h-18" />
|
|
||||||
<path class="trace" d="M220 156h-28v24h-36v-20h-16" />
|
|
||||||
<path class="trace" d="M220 194h-42v-18h-24" />
|
|
||||||
<circle class="dot" cx="64" cy="44" r="3" />
|
|
||||||
<circle class="dot" cx="78" cy="114" r="3" />
|
|
||||||
<circle class="dot" cx="60" cy="182" r="3" />
|
|
||||||
<circle class="dot" cx="196" cy="44" r="3" />
|
|
||||||
<circle class="dot" cx="182" cy="112" r="3" />
|
|
||||||
<circle class="dot" cx="194" cy="180" r="3" />
|
|
||||||
|
|
||||||
<g filter="url(#softGlow)">
|
<!-- 终端符号: >_ -->
|
||||||
<rect x="72" y="74" width="114" height="96" rx="12" fill="url(#panel)" stroke="#8af3ff" stroke-width="3" />
|
<g transform="translate(130, 160)" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<rect x="82" y="86" width="94" height="72" rx="10" fill="#101c35" stroke="#53d9ff" stroke-opacity="0.7" />
|
<!-- 箭头 > -->
|
||||||
<circle cx="90" cy="80" r="3.4" class="lightR" />
|
<path d="M 20 20 L 120 90 L 20 160" fill="none" stroke="#3b82f6" stroke-width="40" />
|
||||||
<circle cx="98" cy="80" r="3.4" class="lightY" />
|
|
||||||
<circle cx="106" cy="80" r="3.4" class="lightG" />
|
<!-- 下划线 _ -->
|
||||||
<text x="94" y="103" font-size="8.5" fill="#baf7ff" font-family="IBM Plex Mono, monospace">$ ssh -i keys/mgmt.pem</text>
|
<line x1="140" y1="180" x2="240" y2="180" stroke="#10b981" stroke-width="36" />
|
||||||
<text x="94" y="114" font-size="7.8" fill="#8bcfe2" font-family="IBM Plex Mono, monospace">admin@prod-1.net</text>
|
|
||||||
<text x="92" y="132" font-size="34" fill="#b4ffff" font-family="IBM Plex Sans, sans-serif" font-weight="700">$</text>
|
|
||||||
<rect x="108" y="125" width="24" height="6" rx="3" fill="url(#neon)" />
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g transform="translate(127 148)" filter="url(#glow)">
|
<!-- 顶部状态指示灯 (红黄绿) -->
|
||||||
<circle r="47" fill="rgba(10,29,63,0.5)" stroke="#5ce8ff" stroke-width="1.8" />
|
<circle cx="110" cy="110" r="12" fill="#ef4444" />
|
||||||
<circle r="36" fill="none" stroke="#2fc7ff" stroke-width="1.4" stroke-opacity="0.5" />
|
<circle cx="150" cy="110" r="12" fill="#eab308" />
|
||||||
<circle cx="-45" cy="0" r="3.4" fill="#55efff" />
|
<circle cx="190" cy="110" r="12" fill="#10b981" />
|
||||||
<circle cx="45" cy="0" r="3.4" fill="#55efff" />
|
|
||||||
<circle cx="0" cy="-45" r="3.4" fill="#55efff" />
|
|
||||||
<circle cx="0" cy="45" r="3.4" fill="#55efff" />
|
|
||||||
|
|
||||||
<rect class="rack" x="-27" y="-24" width="22" height="10" rx="3" />
|
|
||||||
<rect class="rack" x="-27" y="-10" width="22" height="10" rx="3" />
|
|
||||||
<rect class="rack" x="-27" y="4" width="22" height="10" rx="3" />
|
|
||||||
<rect class="rack" x="-27" y="18" width="22" height="10" rx="3" />
|
|
||||||
<rect class="rack" x="5" y="-24" width="22" height="10" rx="3" />
|
|
||||||
<rect class="rack" x="5" y="-10" width="22" height="10" rx="3" />
|
|
||||||
<rect class="rack" x="5" y="4" width="22" height="10" rx="3" />
|
|
||||||
<rect class="rack" x="5" y="18" width="22" height="10" rx="3" />
|
|
||||||
|
|
||||||
<circle cx="-10" cy="-19" r="1.6" class="lightG" />
|
|
||||||
<circle cx="-14" cy="-5" r="1.6" class="lightY" />
|
|
||||||
<circle cx="-8" cy="9" r="1.6" class="lightG" />
|
|
||||||
<circle cx="-12" cy="23" r="1.6" class="lightR" />
|
|
||||||
<circle cx="22" cy="-19" r="1.6" class="lightY" />
|
|
||||||
<circle cx="18" cy="-5" r="1.6" class="lightG" />
|
|
||||||
<circle cx="24" cy="9" r="1.6" class="lightY" />
|
|
||||||
<circle cx="20" cy="23" r="1.6" class="lightG" />
|
|
||||||
|
|
||||||
<path class="trace2" d="M-16 -14v-10c0-7 5-12 12-12h8c7 0 12 5 12 12v10" />
|
|
||||||
<path class="trace2" d="M-16 18v10c0 7 5 12 12 12h8c7 0 12-5 12-12v-10" />
|
|
||||||
<path class="trace2" d="M-5 -14v28" />
|
|
||||||
<path class="trace2" d="M5 -14v28" />
|
|
||||||
<rect x="-6" y="32" width="12" height="8" rx="2.2" fill="#17345f" stroke="#79f7ff" stroke-width="1.4" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,16 +1,33 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { CheckCircle2, Clock, Command, Copy, Play, RefreshCw, Server, Terminal, XCircle } from 'lucide-react'
|
import { CheckCircle2, Clock, Command, Copy, Play, RefreshCw, Server, Terminal, XCircle } from 'lucide-react'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import { executeBatchCommand } from '../services/connections'
|
import { executeBatchCommand } from '../services/connections'
|
||||||
import type { BatchCommandResult, Connection } from '../types'
|
import type { BatchCommandResult, Connection, ConnectionReachabilityStatus, ConnectionStatusItem } from '../types'
|
||||||
|
|
||||||
|
const reachabilityCopy: Record<ConnectionReachabilityStatus, { dot: string; label: string; text: string }> = {
|
||||||
|
unknown: { dot: 'bg-slate-500', label: '未检测', text: 'text-slate-500' },
|
||||||
|
checking: { dot: 'bg-amber-400', label: '检测中', text: 'text-amber-300' },
|
||||||
|
online: { dot: 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.5)]', label: '在线', text: 'text-emerald-500/80' },
|
||||||
|
offline: { dot: 'bg-slate-500', label: '离线', text: 'text-slate-500' },
|
||||||
|
}
|
||||||
|
|
||||||
export default function BatchCommandModal({
|
export default function BatchCommandModal({
|
||||||
open,
|
open,
|
||||||
connections,
|
connections,
|
||||||
|
connectionStatuses,
|
||||||
|
connectionStatusDetails,
|
||||||
|
onRefreshStatuses,
|
||||||
|
statusError,
|
||||||
|
statusLoading,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
connections: Connection[]
|
connections: Connection[]
|
||||||
|
connectionStatuses: Record<number, ConnectionReachabilityStatus>
|
||||||
|
connectionStatusDetails: Record<number, ConnectionStatusItem>
|
||||||
|
onRefreshStatuses: () => Promise<void>
|
||||||
|
statusError: string | null
|
||||||
|
statusLoading: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>(() => connections.slice(0, 2).map((item) => item.id))
|
const [selectedIds, setSelectedIds] = useState<number[]>(() => connections.slice(0, 2).map((item) => item.id))
|
||||||
@@ -28,14 +45,35 @@ export default function BatchCommandModal({
|
|||||||
[results, selectedIds.length],
|
[results, selectedIds.length],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onlineIds = useMemo(
|
||||||
|
() => connections.filter((item) => connectionStatuses[item.id] === 'online').map((item) => item.id),
|
||||||
|
[connectionStatuses, connections],
|
||||||
|
)
|
||||||
|
|
||||||
|
const offlineCount = useMemo(
|
||||||
|
() => connections.filter((item) => connectionStatuses[item.id] === 'offline').length,
|
||||||
|
[connectionStatuses, connections],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(id) =>
|
||||||
|
connections.some((item) => item.id === id) &&
|
||||||
|
connectionStatuses[id] === 'online',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, [connectionStatuses, connections])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
async function handleRun() {
|
async function handleRun() {
|
||||||
if (!selectedIds.length || !command.trim()) return
|
const runnableIds = selectedIds.filter((id) => connectionStatuses[id] === 'online')
|
||||||
|
if (!runnableIds.length || !command.trim()) return
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const response = await executeBatchCommand(selectedIds, command)
|
const response = await executeBatchCommand(runnableIds, command)
|
||||||
setResults(response.data.results)
|
setResults(response.data.results)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
@@ -53,21 +91,45 @@ export default function BatchCommandModal({
|
|||||||
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm text-slate-300">
|
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm text-slate-300">
|
||||||
<span>目标主机 ({selectedIds.length})</span>
|
<span>目标主机 ({selectedIds.length})</span>
|
||||||
<div className="flex gap-3 text-xs">
|
<div className="flex gap-3 text-xs">
|
||||||
<button className="text-blue-400" onClick={() => setSelectedIds(connections.map((item) => item.id))}>
|
<button className="text-blue-400 disabled:text-slate-600" disabled={statusLoading} onClick={() => setSelectedIds(onlineIds)}>
|
||||||
全选
|
全选
|
||||||
</button>
|
</button>
|
||||||
<button className="text-slate-400" onClick={() => setSelectedIds([])}>
|
<button className="text-slate-400" onClick={() => setSelectedIds([])}>
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-slate-400 transition hover:text-slate-200 disabled:text-slate-600"
|
||||||
|
disabled={statusLoading || connections.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
void onRefreshStatuses().catch(() => undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={statusLoading ? 'animate-spin' : ''} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-b border-slate-800 px-4 py-2 text-xs text-slate-500">
|
||||||
|
在线 {onlineIds.length} / 离线 {offlineCount} / 总数 {connections.length}
|
||||||
|
</div>
|
||||||
<div className="flex-1 space-y-1 overflow-auto p-2">
|
<div className="flex-1 space-y-1 overflow-auto p-2">
|
||||||
{connections.map((connection) => {
|
{connections.map((connection) => {
|
||||||
const checked = selectedIds.includes(connection.id)
|
const checked = selectedIds.includes(connection.id)
|
||||||
|
const reachability = connectionStatuses[connection.id] ?? 'unknown'
|
||||||
|
const reachabilityMeta = reachabilityCopy[reachability]
|
||||||
|
const statusDetail = connectionStatusDetails[connection.id]
|
||||||
|
const disabled = reachability !== 'online'
|
||||||
return (
|
return (
|
||||||
<label key={connection.id} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2 transition hover:bg-slate-800">
|
<label
|
||||||
|
key={connection.id}
|
||||||
|
title={statusDetail?.message || reachabilityMeta.label}
|
||||||
|
className={`flex items-center gap-2 rounded-xl px-3 py-2 transition ${
|
||||||
|
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
disabled={disabled}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
setSelectedIds((prev) =>
|
setSelectedIds((prev) =>
|
||||||
@@ -75,8 +137,14 @@ export default function BatchCommandModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Server size={14} className={checked ? 'text-blue-400' : 'text-slate-500'} />
|
<div className="relative shrink-0">
|
||||||
<span className="truncate text-sm text-slate-300">{connection.name}</span>
|
<Server size={14} className={checked && !disabled ? 'text-blue-400' : 'text-slate-500'} />
|
||||||
|
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-slate-900 ${reachabilityMeta.dot}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm text-slate-300">{connection.name}</div>
|
||||||
|
<div className={`text-[10px] ${reachabilityMeta.text}`}>{reachabilityMeta.label}</div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -100,7 +168,7 @@ export default function BatchCommandModal({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="flex h-[46px] items-center gap-2 rounded-xl bg-blue-600 px-6 text-white transition hover:bg-blue-500 disabled:opacity-60"
|
className="flex h-[46px] items-center gap-2 rounded-xl bg-blue-600 px-6 text-white transition hover:bg-blue-500 disabled:opacity-60"
|
||||||
disabled={running || selectedIds.length === 0}
|
disabled={running || statusLoading || selectedIds.filter((id) => connectionStatuses[id] === 'online').length === 0}
|
||||||
onClick={() => void handleRun()}
|
onClick={() => void handleRun()}
|
||||||
>
|
>
|
||||||
{running ? <RefreshCw size={16} className="animate-spin" /> : <Play size={16} fill="currentColor" />}
|
{running ? <RefreshCw size={16} className="animate-spin" /> : <Play size={16} fill="currentColor" />}
|
||||||
@@ -116,11 +184,12 @@ export default function BatchCommandModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 overflow-auto p-4">
|
<div className="flex-1 space-y-4 overflow-auto p-4">
|
||||||
|
{statusError ? <div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">{statusError}</div> : null}
|
||||||
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||||
{!results.length && !error ? (
|
{!results.length && !error ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-slate-500">
|
<div className="flex h-full flex-col items-center justify-center text-slate-500">
|
||||||
<Command size={48} className="mb-3 text-slate-700" />
|
<Command size={48} className="mb-3 text-slate-700" />
|
||||||
<p>请在上方输入命令并点击执行</p>
|
<p>{statusLoading ? '正在检测主机状态...' : '请在上方输入命令并点击执行'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{results.map((result) => (
|
{results.map((result) => (
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Connection, ConnectionCreateRequest } from '../types'
|
import type {
|
||||||
|
Connection,
|
||||||
|
ConnectionCreateRequest,
|
||||||
|
ConnectionModalSubmitPayload,
|
||||||
|
SessionTreeFolderOption,
|
||||||
|
} from '../types'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
|
|
||||||
const emptyForm: ConnectionCreateRequest = {
|
const emptyForm: ConnectionCreateRequest = {
|
||||||
@@ -18,15 +23,20 @@ const emptyForm: ConnectionCreateRequest = {
|
|||||||
export default function ConnectionModal({
|
export default function ConnectionModal({
|
||||||
open,
|
open,
|
||||||
connection,
|
connection,
|
||||||
|
folderOptions,
|
||||||
|
initialTargetFolderId,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
connection?: Connection | null
|
connection?: Connection | null
|
||||||
|
folderOptions: SessionTreeFolderOption[]
|
||||||
|
initialTargetFolderId: string | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (payload: ConnectionCreateRequest) => Promise<void>
|
onSubmit: (payload: ConnectionModalSubmitPayload) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<ConnectionCreateRequest>(emptyForm)
|
const [form, setForm] = useState<ConnectionCreateRequest>(emptyForm)
|
||||||
|
const [targetFolderId, setTargetFolderId] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -48,8 +58,9 @@ export default function ConnectionModal({
|
|||||||
} else {
|
} else {
|
||||||
setForm(emptyForm)
|
setForm(emptyForm)
|
||||||
}
|
}
|
||||||
|
setTargetFolderId(initialTargetFolderId)
|
||||||
setError(null)
|
setError(null)
|
||||||
}, [connection, open])
|
}, [connection, initialTargetFolderId, open])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
@@ -63,8 +74,8 @@ export default function ConnectionModal({
|
|||||||
await onSubmit({
|
await onSubmit({
|
||||||
...form,
|
...form,
|
||||||
port: Number(form.port || 22),
|
port: Number(form.port || 22),
|
||||||
|
targetFolderId,
|
||||||
})
|
})
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '保存连接失败'
|
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '保存连接失败'
|
||||||
@@ -97,38 +108,79 @@ export default function ConnectionModal({
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm text-slate-300">连接名称</span>
|
<span className="text-sm text-slate-300">连接名称</span>
|
||||||
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.name} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} />
|
<input
|
||||||
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
|
placeholder="例如:prod-web-01"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm text-slate-300">主机 IP</span>
|
<span className="text-sm text-slate-300">分组 / 父文件夹</span>
|
||||||
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.host} onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))} />
|
<select
|
||||||
</label>
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
<label className="space-y-2">
|
value={targetFolderId ?? '__ROOT__'}
|
||||||
<span className="text-sm text-slate-300">端口</span>
|
onChange={(event) => setTargetFolderId(event.target.value === '__ROOT__' ? null : event.target.value)}
|
||||||
<input type="number" className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.port ?? 22} onChange={(e) => setForm((prev) => ({ ...prev, port: Number(e.target.value) }))} />
|
>
|
||||||
</label>
|
<option value="__ROOT__">根目录</option>
|
||||||
<label className="space-y-2">
|
{folderOptions.map((option) => (
|
||||||
<span className="text-sm text-slate-300">用户名</span>
|
<option key={option.id} value={option.id}>
|
||||||
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.username} onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))} />
|
{`${'— '.repeat(option.depth)}${option.name}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-4 border-t border-slate-800 pt-5">
|
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1.4fr)_120px_minmax(0,1fr)]">
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">主机 IP</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">端口</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
|
value={form.port ?? 22}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, port: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">用户名</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-[28px] border border-slate-800 bg-slate-950/40 p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-sm font-medium text-slate-100">认证方式</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">保留现有密码、私钥和一键免密部署能力。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
className={`rounded-xl border px-4 py-2 text-sm ${isPassword ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
className={`rounded-2xl border px-4 py-2 text-sm ${isPassword ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||||
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'PASSWORD_BOOTSTRAP' : 'NONE' }))}
|
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'PASSWORD_BOOTSTRAP' : 'NONE' }))}
|
||||||
>
|
>
|
||||||
密码认证
|
密码认证
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`rounded-xl border px-4 py-2 text-sm ${form.authType === 'PRIVATE_KEY' ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
className={`rounded-2xl border px-4 py-2 text-sm ${form.authType === 'PRIVATE_KEY' ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||||
onClick={() => setForm((prev) => ({ ...prev, authType: 'PRIVATE_KEY', setupMode: 'NONE' }))}
|
onClick={() => setForm((prev) => ({ ...prev, authType: 'PRIVATE_KEY', setupMode: 'NONE' }))}
|
||||||
>
|
>
|
||||||
私钥认证
|
私钥认证
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`rounded-xl border px-4 py-2 text-sm ${useBootstrap ? 'border-emerald-500 bg-emerald-500/10 text-emerald-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
className={`rounded-2xl border px-4 py-2 text-sm ${useBootstrap ? 'border-emerald-500 bg-emerald-500/10 text-emerald-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||||
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'NONE' : 'PASSWORD_BOOTSTRAP' }))}
|
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'NONE' : 'PASSWORD_BOOTSTRAP' }))}
|
||||||
>
|
>
|
||||||
一键免密部署
|
一键免密部署
|
||||||
@@ -140,7 +192,7 @@ export default function ConnectionModal({
|
|||||||
<span className="text-sm text-slate-300">{useBootstrap ? '引导密码' : '密码'}</span>
|
<span className="text-sm text-slate-300">{useBootstrap ? '引导密码' : '密码'}</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white"
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
value={useBootstrap ? form.bootstrapPassword ?? '' : form.password ?? ''}
|
value={useBootstrap ? form.bootstrapPassword ?? '' : form.password ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) =>
|
setForm((prev) =>
|
||||||
@@ -155,7 +207,7 @@ export default function ConnectionModal({
|
|||||||
<span className="text-sm text-slate-300">私钥内容</span>
|
<span className="text-sm text-slate-300">私钥内容</span>
|
||||||
<textarea
|
<textarea
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 font-mono text-sm text-white"
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 font-mono text-sm text-white outline-none focus:border-blue-500"
|
||||||
value={form.privateKey ?? ''}
|
value={form.privateKey ?? ''}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, privateKey: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, privateKey: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +216,7 @@ export default function ConnectionModal({
|
|||||||
<span className="text-sm text-slate-300">私钥口令</span>
|
<span className="text-sm text-slate-300">私钥口令</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white"
|
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
value={form.passphrase ?? ''}
|
value={form.passphrase ?? ''}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, passphrase: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, passphrase: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { SessionTreeFolderOption } from '../types'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
|
export default function FolderModal({
|
||||||
|
open,
|
||||||
|
folderOptions,
|
||||||
|
initialParentId,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
folderOptions: SessionTreeFolderOption[]
|
||||||
|
initialParentId: string | null
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (payload: { name: string; parentId: string | null }) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [parentId, setParentId] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setName('')
|
||||||
|
setParentId(initialParentId)
|
||||||
|
setSubmitting(false)
|
||||||
|
setError(null)
|
||||||
|
}, [initialParentId, open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError('请输入文件夹名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await onSubmit({ name: trimmedName, parentId })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '创建文件夹失败'
|
||||||
|
setError(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="新建文件夹"
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="max-w-lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="rounded-xl bg-slate-700 px-4 py-2 text-sm text-slate-200 transition hover:bg-slate-600" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-xl bg-blue-600 px-4 py-2 text-sm text-white transition hover:bg-blue-500 disabled:opacity-60"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{submitting ? '创建中...' : '创建文件夹'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">文件夹名称</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
|
placeholder="例如:生产环境"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">父级目录(可选)</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||||
|
value={parentId ?? '__ROOT__'}
|
||||||
|
onChange={(event) => setParentId(event.target.value === '__ROOT__' ? null : event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="__ROOT__">根目录</option>
|
||||||
|
{folderOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{`${'— '.repeat(option.depth)}${option.name}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,34 @@
|
|||||||
|
import type { MouseEvent } from 'react'
|
||||||
import { ChevronDown, ChevronRight, Folder, Server } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Folder, Server } from 'lucide-react'
|
||||||
import type { Connection } from '../types'
|
import type { Connection, ConnectionReachabilityStatus } from '../types'
|
||||||
import type { BuiltTreeNode } from '../lib/utils'
|
import type { BuiltTreeNode } from '../lib/utils'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
|
|
||||||
|
interface SessionTreeContextMenuPayload {
|
||||||
|
nodeId: string
|
||||||
|
nodeType: 'folder' | 'connection'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionTreeProps {
|
interface SessionTreeProps {
|
||||||
nodes: BuiltTreeNode[]
|
nodes: BuiltTreeNode[]
|
||||||
activeConnectionId: number | null
|
activeConnectionId: number | null
|
||||||
|
connectionStatuses?: Record<number, ConnectionReachabilityStatus>
|
||||||
|
openConnectionIds: number[]
|
||||||
|
selectedNodeId: string | null
|
||||||
search: string
|
search: string
|
||||||
|
onSelectNode: (nodeId: string) => void
|
||||||
onToggleFolder: (nodeId: string) => void
|
onToggleFolder: (nodeId: string) => void
|
||||||
onOpenConnection: (connection: Connection) => void
|
onOpenConnection: (connection: Connection, nodeId: string) => void
|
||||||
|
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCopy: Record<ConnectionReachabilityStatus, { dot: string; label: string; text: string }> = {
|
||||||
|
unknown: { dot: 'bg-slate-500', label: '未检测', text: 'text-slate-500' },
|
||||||
|
checking: { dot: 'bg-amber-400', label: '检测中', text: 'text-amber-300' },
|
||||||
|
online: { dot: 'bg-emerald-500', label: '在线', text: 'text-emerald-400' },
|
||||||
|
offline: { dot: 'bg-red-500', label: '离线', text: 'text-red-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function matches(node: BuiltTreeNode, term: string): boolean {
|
function matches(node: BuiltTreeNode, term: string): boolean {
|
||||||
@@ -22,26 +42,55 @@ function TreeNode({
|
|||||||
node,
|
node,
|
||||||
depth,
|
depth,
|
||||||
activeConnectionId,
|
activeConnectionId,
|
||||||
|
connectionStatuses,
|
||||||
|
openConnectionIds,
|
||||||
|
selectedNodeId,
|
||||||
search,
|
search,
|
||||||
|
onSelectNode,
|
||||||
onToggleFolder,
|
onToggleFolder,
|
||||||
onOpenConnection,
|
onOpenConnection,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
node: BuiltTreeNode
|
node: BuiltTreeNode
|
||||||
depth: number
|
depth: number
|
||||||
activeConnectionId: number | null
|
activeConnectionId: number | null
|
||||||
|
connectionStatuses?: Record<number, ConnectionReachabilityStatus>
|
||||||
|
openConnectionIds: number[]
|
||||||
|
selectedNodeId: string | null
|
||||||
search: string
|
search: string
|
||||||
|
onSelectNode: (nodeId: string) => void
|
||||||
onToggleFolder: (nodeId: string) => void
|
onToggleFolder: (nodeId: string) => void
|
||||||
onOpenConnection: (connection: Connection) => void
|
onOpenConnection: (connection: Connection, nodeId: string) => void
|
||||||
|
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
|
||||||
}) {
|
}) {
|
||||||
|
function handleContextMenu(event: MouseEvent<HTMLButtonElement>, nodeType: 'folder' | 'connection') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onContextMenu({
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeType,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!matches(node, search)) return null
|
if (!matches(node, search)) return null
|
||||||
|
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
const expanded = node.expanded ?? true
|
const expanded = node.expanded ?? true
|
||||||
|
const selected = selectedNodeId === node.id
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm text-slate-300 transition hover:bg-slate-800"
|
className={cn(
|
||||||
onClick={() => onToggleFolder(node.id)}
|
'flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm transition',
|
||||||
|
selected ? 'bg-slate-800 text-slate-100 ring-1 ring-inset ring-slate-700' : 'text-slate-300 hover:bg-slate-800',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectNode(node.id)
|
||||||
|
onToggleFolder(node.id)
|
||||||
|
}}
|
||||||
|
onContextMenu={(event) => handleContextMenu(event, 'folder')}
|
||||||
style={{ paddingLeft: 8 + depth * 16 }}
|
style={{ paddingLeft: 8 + depth * 16 }}
|
||||||
>
|
>
|
||||||
{expanded ? <ChevronDown size={14} className="text-slate-500" /> : <ChevronRight size={14} className="text-slate-500" />}
|
{expanded ? <ChevronDown size={14} className="text-slate-500" /> : <ChevronRight size={14} className="text-slate-500" />}
|
||||||
@@ -56,9 +105,14 @@ function TreeNode({
|
|||||||
node={child}
|
node={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
activeConnectionId={activeConnectionId}
|
activeConnectionId={activeConnectionId}
|
||||||
|
connectionStatuses={connectionStatuses}
|
||||||
|
openConnectionIds={openConnectionIds}
|
||||||
|
selectedNodeId={selectedNodeId}
|
||||||
search={search}
|
search={search}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
onToggleFolder={onToggleFolder}
|
onToggleFolder={onToggleFolder}
|
||||||
onOpenConnection={onOpenConnection}
|
onOpenConnection={onOpenConnection}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -69,20 +123,48 @@ function TreeNode({
|
|||||||
|
|
||||||
if (!node.connection) return null
|
if (!node.connection) return null
|
||||||
|
|
||||||
|
const selected = selectedNodeId === node.id
|
||||||
const active = activeConnectionId === node.connection.id
|
const active = activeConnectionId === node.connection.id
|
||||||
|
const opened = openConnectionIds.includes(node.connection.id)
|
||||||
|
const reachability = connectionStatuses?.[node.connection.id] ?? 'unknown'
|
||||||
|
const reachabilityMeta = statusCopy[reachability]
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm transition',
|
'group flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm transition',
|
||||||
active ? 'bg-blue-600/20 text-blue-300' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
|
active
|
||||||
|
? 'bg-blue-600/20 text-blue-200 ring-1 ring-inset ring-blue-500/40'
|
||||||
|
: selected
|
||||||
|
? 'bg-slate-800 text-slate-100 ring-1 ring-inset ring-slate-700'
|
||||||
|
: opened
|
||||||
|
? 'text-emerald-200 hover:bg-slate-800'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
|
||||||
)}
|
)}
|
||||||
onDoubleClick={() => onOpenConnection(node.connection!)}
|
onDoubleClick={() => onOpenConnection(node.connection!, node.id)}
|
||||||
onClick={() => onOpenConnection(node.connection!)}
|
onClick={() => {
|
||||||
|
onSelectNode(node.id)
|
||||||
|
if (opened && !active) {
|
||||||
|
onOpenConnection(node.connection!, node.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(event) => handleContextMenu(event, 'connection')}
|
||||||
style={{ paddingLeft: 24 + depth * 16 }}
|
style={{ paddingLeft: 24 + depth * 16 }}
|
||||||
>
|
>
|
||||||
<Server size={14} className={active ? 'text-blue-300' : 'text-emerald-400'} />
|
<div className="relative shrink-0">
|
||||||
<span className="flex-1 truncate">{node.connection.name}</span>
|
<Server size={14} className={active ? 'text-blue-300' : opened ? 'text-emerald-400' : 'text-slate-500'} />
|
||||||
<span className="hidden rounded bg-slate-950 px-1.5 py-0.5 text-[10px] text-slate-500 group-hover:inline-block">双击连接</span>
|
<span className={cn('absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-slate-900', reachabilityMeta.dot)} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate">{node.connection.name}</div>
|
||||||
|
<div className={cn('text-[10px]', reachabilityMeta.text)}>{reachabilityMeta.label}</div>
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<span className="rounded-full border border-blue-500/30 bg-blue-500/10 px-2 py-0.5 text-[10px] text-blue-200">当前</span>
|
||||||
|
) : opened ? (
|
||||||
|
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-200">已打开</span>
|
||||||
|
) : (
|
||||||
|
<span className="hidden rounded bg-slate-950 px-1.5 py-0.5 text-[10px] text-slate-500 group-hover:inline-block">双击连接</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { Terminal } from 'xterm'
|
import { Terminal } from 'xterm'
|
||||||
import { Activity, Folder, HardDrive, RefreshCw, Search, TerminalSquare } from 'lucide-react'
|
|
||||||
import 'xterm/css/xterm.css'
|
import 'xterm/css/xterm.css'
|
||||||
import { getMetrics } from '../services/monitor'
|
import type { Connection, TerminalConnectionStatus } from '../types'
|
||||||
import { formatBytes } from '../lib/utils'
|
|
||||||
import type { Connection, MonitorMetrics, WorkspaceLayout } from '../types'
|
|
||||||
|
|
||||||
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||||
|
|
||||||
export default function TerminalPane({
|
export default function TerminalPane({
|
||||||
connection,
|
connection,
|
||||||
active,
|
visible,
|
||||||
layout,
|
|
||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
onLayoutChange,
|
onStatusChange,
|
||||||
}: {
|
}: {
|
||||||
connection: Connection
|
connection: Connection
|
||||||
active: boolean
|
visible: boolean
|
||||||
layout: WorkspaceLayout
|
|
||||||
fontSize: number
|
fontSize: number
|
||||||
fontFamily: string
|
fontFamily: string
|
||||||
onLayoutChange: (layout: WorkspaceLayout) => void
|
onStatusChange?: (status: TerminalConnectionStatus) => void
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const termRef = useRef<Terminal | null>(null)
|
const termRef = useRef<Terminal | null>(null)
|
||||||
@@ -30,9 +25,28 @@ export default function TerminalPane({
|
|||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null)
|
const resizeObserverRef = useRef<ResizeObserver | null>(null)
|
||||||
const reconnectTimerRef = useRef<number | null>(null)
|
const reconnectTimerRef = useRef<number | null>(null)
|
||||||
const monitorTimerRef = useRef<number | null>(null)
|
const visibleRef = useRef(visible)
|
||||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'reconnecting' | 'error'>('connecting')
|
const syncViewportRef = useRef<() => void>(() => {})
|
||||||
const [metrics, setMetrics] = useState<MonitorMetrics>({})
|
const [status, setStatus] = useState<TerminalConnectionStatus>('connecting')
|
||||||
|
|
||||||
|
visibleRef.current = visible
|
||||||
|
|
||||||
|
syncViewportRef.current = () => {
|
||||||
|
if (!visibleRef.current) return
|
||||||
|
|
||||||
|
const term = termRef.current
|
||||||
|
if (!term) return
|
||||||
|
|
||||||
|
fitAddonRef.current?.fit()
|
||||||
|
|
||||||
|
const ws = wsRef.current
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStatusChange?.(status)
|
||||||
|
}, [onStatusChange, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false
|
let disposed = false
|
||||||
@@ -52,8 +66,25 @@ export default function TerminalPane({
|
|||||||
termRef.current = term
|
termRef.current = term
|
||||||
fitAddonRef.current = fit
|
fitAddonRef.current = fit
|
||||||
term.open(containerRef.current!)
|
term.open(containerRef.current!)
|
||||||
fit.fit()
|
if (visible) {
|
||||||
term.focus()
|
syncViewportRef.current()
|
||||||
|
term.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDisposable = term.onData((data) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resizeDisposable = term.onResize(() => {
|
||||||
|
syncViewportRef.current()
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserverRef.current = new ResizeObserver(() => {
|
||||||
|
syncViewportRef.current()
|
||||||
|
})
|
||||||
|
resizeObserverRef.current.observe(containerRef.current!)
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
@@ -69,8 +100,7 @@ export default function TerminalPane({
|
|||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setStatus('connected')
|
setStatus('connected')
|
||||||
fit.fit()
|
syncViewportRef.current()
|
||||||
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
||||||
}
|
}
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
term.write(typeof event.data === 'string' ? event.data : '')
|
term.write(typeof event.data === 'string' ? event.data : '')
|
||||||
@@ -83,95 +113,38 @@ export default function TerminalPane({
|
|||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
}
|
}
|
||||||
const disposable = term.onData((data) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const resizeDisposable = term.onResize(({ cols, rows }) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols, rows }))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
resizeObserverRef.current = new ResizeObserver(() => {
|
|
||||||
fit.fit()
|
|
||||||
})
|
|
||||||
resizeObserverRef.current.observe(containerRef.current!)
|
|
||||||
return () => {
|
|
||||||
disposable.dispose()
|
|
||||||
resizeDisposable.dispose()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const disposeTerminalEvents = connect()
|
connect()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
if (monitorTimerRef.current) window.clearInterval(monitorTimerRef.current)
|
|
||||||
if (reconnectTimerRef.current) window.clearTimeout(reconnectTimerRef.current)
|
if (reconnectTimerRef.current) window.clearTimeout(reconnectTimerRef.current)
|
||||||
resizeObserverRef.current?.disconnect()
|
resizeObserverRef.current?.disconnect()
|
||||||
wsRef.current?.close()
|
wsRef.current?.close()
|
||||||
disposeTerminalEvents?.()
|
dataDisposable.dispose()
|
||||||
|
resizeDisposable.dispose()
|
||||||
term.dispose()
|
term.dispose()
|
||||||
}
|
}
|
||||||
}, [connection.id, fontFamily, fontSize])
|
}, [connection.id, fontFamily, fontSize])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return
|
if (!visible) return
|
||||||
const fetchMetrics = async () => {
|
|
||||||
try {
|
const frame = window.requestAnimationFrame(() => {
|
||||||
const response = await getMetrics(connection.id)
|
syncViewportRef.current()
|
||||||
setMetrics(response.data)
|
termRef.current?.focus()
|
||||||
} catch {
|
})
|
||||||
// keep terminal usable even when monitor fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void fetchMetrics()
|
|
||||||
monitorTimerRef.current = window.setInterval(fetchMetrics, 5000)
|
|
||||||
return () => {
|
return () => {
|
||||||
if (monitorTimerRef.current) window.clearInterval(monitorTimerRef.current)
|
window.cancelAnimationFrame(frame)
|
||||||
}
|
}
|
||||||
}, [active, connection.id])
|
}, [visible])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-slate-900">
|
<div className="flex h-full flex-col overflow-hidden bg-slate-900">
|
||||||
<div className="flex h-10 items-center justify-between border-b border-slate-800 bg-slate-800/80 px-4 text-xs text-slate-400">
|
<div className="flex-1 bg-black p-2 font-mono">
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Activity size={14} className="text-emerald-400" />
|
|
||||||
CPU: {metrics.cpuUsage ?? '-'}%
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<HardDrive size={14} className="text-blue-400" />
|
|
||||||
MEM: {formatBytes(metrics.memUsed ?? null)} / {formatBytes(metrics.memTotal ?? null)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-2 text-emerald-400">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
|
||||||
{status === 'connected' ? 'WebSocket 已连接' : status === 'reconnecting' ? '连接中断,重试中' : '连接建立中'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button className={`rounded p-1.5 ${layout === 'terminal' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('terminal')}>
|
|
||||||
<TerminalSquare size={16} />
|
|
||||||
</button>
|
|
||||||
<button className={`rounded p-1.5 ${layout === 'sftp' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('sftp')}>
|
|
||||||
<Folder size={16} />
|
|
||||||
</button>
|
|
||||||
<button className={`rounded p-1.5 ${layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('split')}>
|
|
||||||
<Activity size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="group relative flex-1 bg-black p-2 font-mono">
|
|
||||||
<div ref={containerRef} className="h-full w-full overflow-hidden rounded-2xl border border-slate-900 bg-black" />
|
<div ref={containerRef} className="h-full w-full overflow-hidden rounded-2xl border border-slate-900 bg-black" />
|
||||||
<div className="absolute right-6 top-4 flex rounded-xl border border-slate-700 bg-slate-900/80 opacity-0 transition group-hover:opacity-100">
|
|
||||||
<button className="p-2 text-slate-400 transition hover:text-white" onClick={() => termRef.current?.clear()}>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
</button>
|
|
||||||
<button className="p-2 text-slate-400 transition hover:text-white" onClick={() => termRef.current?.focus()}>
|
|
||||||
<Search size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+324
-58
@@ -1,4 +1,9 @@
|
|||||||
import type { Connection, SessionTreeLayoutPayload, SessionTreeNodePayload } from '../types'
|
import type {
|
||||||
|
Connection,
|
||||||
|
SessionTreeFolderOption,
|
||||||
|
SessionTreeLayoutPayload,
|
||||||
|
SessionTreeNodePayload,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
export interface BuiltTreeNode {
|
export interface BuiltTreeNode {
|
||||||
id: string
|
id: string
|
||||||
@@ -48,14 +53,63 @@ export function formatSftpPermissions(entry: { directory: boolean }) {
|
|||||||
return entry.directory ? 'drwxr-xr-x' : '-rw-r--r--'
|
return entry.directory ? 'drwxr-xr-x' : '-rw-r--r--'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortBuiltNodes(items: BuiltTreeNode[]) {
|
||||||
|
items.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
||||||
|
items.forEach((item) => sortBuiltNodes(item.children))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortConnections(connections: Connection[]) {
|
||||||
|
return connections.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNodeId(prefix: 'folder' | 'connection', suffix: string) {
|
||||||
|
return `${prefix}-${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectionNode(
|
||||||
|
connection: Connection,
|
||||||
|
parentId: string | null,
|
||||||
|
order: number,
|
||||||
|
now: number,
|
||||||
|
): SessionTreeNodePayload {
|
||||||
|
return {
|
||||||
|
id: generateNodeId('connection', String(connection.id)),
|
||||||
|
type: 'connection',
|
||||||
|
name: connection.name,
|
||||||
|
parentId,
|
||||||
|
order,
|
||||||
|
connectionId: connection.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFolderId(layout: SessionTreeLayoutPayload | null, nodeId: string | null) {
|
||||||
|
if (!nodeId || !layout) return false
|
||||||
|
return layout.nodes.some((node) => node.id === nodeId && node.type === 'folder')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeParentId(layout: SessionTreeLayoutPayload | null, parentId: string | null) {
|
||||||
|
return isFolderId(layout, parentId) ? parentId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSiblingOrder(nodes: SessionTreeNodePayload[], parentId: string | null, excludeNodeId?: string) {
|
||||||
|
const siblingOrders = nodes
|
||||||
|
.filter((node) => node.id !== excludeNodeId && (node.parentId ?? null) === parentId)
|
||||||
|
.map((node) => node.order)
|
||||||
|
|
||||||
|
return (siblingOrders.length ? Math.max(...siblingOrders) : -1) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function findConnectionNode(layout: SessionTreeLayoutPayload | null, connectionId: number) {
|
||||||
|
return layout?.nodes.find((node) => node.type === 'connection' && node.connectionId === connectionId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connections: Connection[]): BuiltTreeNode[] {
|
export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connections: Connection[]): BuiltTreeNode[] {
|
||||||
const connectionMap = new Map(connections.map((item) => [item.id, item]))
|
const connectionMap = new Map(connections.map((item) => [item.id, item]))
|
||||||
const nodes = layout?.nodes ?? []
|
const nodes = layout?.nodes ?? []
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
return connections
|
return sortConnections(connections).map((connection, index) => ({
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((connection, index) => ({
|
|
||||||
id: `connection-${connection.id}`,
|
id: `connection-${connection.id}`,
|
||||||
type: 'connection',
|
type: 'connection',
|
||||||
name: connection.name,
|
name: connection.name,
|
||||||
@@ -71,7 +125,7 @@ export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connec
|
|||||||
built.set(node.id, {
|
built.set(node.id, {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
name: node.name,
|
name: node.type === 'connection' && node.connectionId ? connectionMap.get(node.connectionId)?.name ?? node.name : node.name,
|
||||||
order: node.order,
|
order: node.order,
|
||||||
parentId: node.parentId,
|
parentId: node.parentId,
|
||||||
expanded: node.expanded,
|
expanded: node.expanded,
|
||||||
@@ -92,24 +146,75 @@ export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortNodes = (items: BuiltTreeNode[]) => {
|
sortBuiltNodes(roots)
|
||||||
items.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
|
||||||
items.forEach((item) => sortNodes(item.children))
|
|
||||||
}
|
|
||||||
sortNodes(roots)
|
|
||||||
return roots
|
return roots
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectConnectionIds(nodes: BuiltTreeNode[]) {
|
export function createInitialSessionTreeLayout(connections: Connection[]): SessionTreeLayoutPayload {
|
||||||
const ids: number[] = []
|
const now = Date.now()
|
||||||
const walk = (list: BuiltTreeNode[]) => {
|
return {
|
||||||
list.forEach((node) => {
|
nodes: sortConnections(connections).map((connection, index) => createConnectionNode(connection, null, index, now)),
|
||||||
if (node.type === 'connection' && node.connection) ids.push(node.connection.id)
|
sortMode: 'manual',
|
||||||
if (node.children.length > 0) walk(node.children)
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export function syncSessionTreeLayout(
|
||||||
|
layout: SessionTreeLayoutPayload | null,
|
||||||
|
connections: Connection[],
|
||||||
|
): SessionTreeLayoutPayload {
|
||||||
|
if (!layout) {
|
||||||
|
return createInitialSessionTreeLayout(connections)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionMap = new Map(connections.map((connection) => [connection.id, connection]))
|
||||||
|
const seenConnectionIds = new Set<number>()
|
||||||
|
const retainedNodes: SessionTreeNodePayload[] = []
|
||||||
|
|
||||||
|
for (const node of layout.nodes) {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
retainedNodes.push(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.connectionId || seenConnectionIds.has(node.connectionId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = connectionMap.get(node.connectionId)
|
||||||
|
if (!connection) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
retainedNodes.push({
|
||||||
|
...node,
|
||||||
|
name: connection.name,
|
||||||
|
})
|
||||||
|
seenConnectionIds.add(node.connectionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLayout: SessionTreeLayoutPayload = {
|
||||||
|
nodes: retainedNodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
parentId: node.parentId && isFolderId({ nodes: retainedNodes }, node.parentId) ? node.parentId : null,
|
||||||
|
})),
|
||||||
|
sortMode: layout.sortMode ?? 'manual',
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingConnections = sortConnections(connections).filter((connection) => !seenConnectionIds.has(connection.id))
|
||||||
|
if (missingConnections.length === 0) {
|
||||||
|
return normalizedLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const nextNodes = normalizedLayout.nodes.slice()
|
||||||
|
missingConnections.forEach((connection) => {
|
||||||
|
nextNodes.push(createConnectionNode(connection, null, nextSiblingOrder(nextNodes, null), now))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalizedLayout,
|
||||||
|
nodes: nextNodes,
|
||||||
}
|
}
|
||||||
walk(nodes)
|
|
||||||
return ids
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateExpandedState(layout: SessionTreeLayoutPayload | null, nodeId: string): SessionTreeLayoutPayload | null {
|
export function updateExpandedState(layout: SessionTreeLayoutPayload | null, nodeId: string): SessionTreeLayoutPayload | null {
|
||||||
@@ -124,45 +229,206 @@ export function updateExpandedState(layout: SessionTreeLayoutPayload | null, nod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDefaultTreeLayout(connections: Connection[]): SessionTreeLayoutPayload {
|
export function updateAllFoldersExpandedState(
|
||||||
|
layout: SessionTreeLayoutPayload | null,
|
||||||
|
expanded: boolean,
|
||||||
|
): SessionTreeLayoutPayload | null {
|
||||||
|
if (!layout) return layout
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const groups = new Map<string, Connection[]>()
|
return {
|
||||||
connections.forEach((connection) => {
|
...layout,
|
||||||
const key = connection.name.includes('-') ? connection.name.split('-')[0] : '默认分组'
|
nodes: layout.nodes.map((node) =>
|
||||||
const group = groups.get(key) ?? []
|
node.type === 'folder'
|
||||||
group.push(connection)
|
? {
|
||||||
groups.set(key, group)
|
...node,
|
||||||
|
expanded,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessionFolderOptions(
|
||||||
|
layout: SessionTreeLayoutPayload | null,
|
||||||
|
connections: Connection[],
|
||||||
|
): SessionTreeFolderOption[] {
|
||||||
|
const options: SessionTreeFolderOption[] = []
|
||||||
|
const walk = (nodes: BuiltTreeNode[], depth: number) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type !== 'folder') return
|
||||||
|
options.push({ id: node.id, name: node.name, depth })
|
||||||
|
walk(node.children, depth + 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(buildSessionTree(layout, connections), 0)
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSuggestedFolderId(layout: SessionTreeLayoutPayload | null, selectedNodeId: string | null) {
|
||||||
|
if (!layout || !selectedNodeId) return null
|
||||||
|
const node = layout.nodes.find((item) => item.id === selectedNodeId)
|
||||||
|
if (!node) return null
|
||||||
|
if (node.type === 'folder') return node.id
|
||||||
|
return normalizeParentId(layout, node.parentId ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findConnectionNodeId(layout: SessionTreeLayoutPayload | null, connectionId: number) {
|
||||||
|
return findConnectionNode(layout, connectionId)?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findConnectionFolderId(layout: SessionTreeLayoutPayload | null, connectionId: number) {
|
||||||
|
return normalizeParentId(layout, findConnectionNode(layout, connectionId)?.parentId ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertFolderNode(
|
||||||
|
layout: SessionTreeLayoutPayload,
|
||||||
|
folderName: string,
|
||||||
|
parentId: string | null,
|
||||||
|
): { layout: SessionTreeLayoutPayload; nodeId: string } {
|
||||||
|
const now = Date.now()
|
||||||
|
const nextParentId = normalizeParentId(layout, parentId)
|
||||||
|
const nodeId = generateNodeId('folder', globalThis.crypto?.randomUUID?.() ?? String(now))
|
||||||
|
const nextNode: SessionTreeNodePayload = {
|
||||||
|
id: nodeId,
|
||||||
|
type: 'folder',
|
||||||
|
name: folderName,
|
||||||
|
parentId: nextParentId,
|
||||||
|
order: nextSiblingOrder(layout.nodes, nextParentId),
|
||||||
|
expanded: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout: {
|
||||||
|
...layout,
|
||||||
|
nodes: [...layout.nodes, nextNode],
|
||||||
|
sortMode: layout.sortMode ?? 'manual',
|
||||||
|
},
|
||||||
|
nodeId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertConnectionNode(
|
||||||
|
layout: SessionTreeLayoutPayload,
|
||||||
|
connection: Connection,
|
||||||
|
parentId: string | null,
|
||||||
|
): SessionTreeLayoutPayload {
|
||||||
|
const now = Date.now()
|
||||||
|
const nextParentId = normalizeParentId(layout, parentId)
|
||||||
|
const existingNode = findConnectionNode(layout, connection.id)
|
||||||
|
|
||||||
|
if (!existingNode) {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
nodes: [
|
||||||
|
...layout.nodes,
|
||||||
|
createConnectionNode(connection, nextParentId, nextSiblingOrder(layout.nodes, nextParentId), now),
|
||||||
|
],
|
||||||
|
sortMode: layout.sortMode ?? 'manual',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextOrder =
|
||||||
|
existingNode.parentId === nextParentId
|
||||||
|
? existingNode.order
|
||||||
|
: nextSiblingOrder(layout.nodes, nextParentId, existingNode.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
nodes: layout.nodes.map((node) =>
|
||||||
|
node.id === existingNode.id
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
name: connection.name,
|
||||||
|
parentId: nextParentId,
|
||||||
|
order: nextOrder,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
sortMode: layout.sortMode ?? 'manual',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameSessionTreeNode(
|
||||||
|
layout: SessionTreeLayoutPayload | null,
|
||||||
|
nodeId: string,
|
||||||
|
name: string,
|
||||||
|
): SessionTreeLayoutPayload | null {
|
||||||
|
if (!layout) return layout
|
||||||
|
|
||||||
|
const nextName = name.trim()
|
||||||
|
if (!nextName) return layout
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
nodes: layout.nodes.map((node) =>
|
||||||
|
node.id === nodeId
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
name: nextName,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
sortMode: layout.sortMode ?? 'manual',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSessionTreeNodeSubtree(
|
||||||
|
layout: SessionTreeLayoutPayload | null,
|
||||||
|
nodeId: string,
|
||||||
|
): {
|
||||||
|
layout: SessionTreeLayoutPayload
|
||||||
|
deletedConnectionIds: number[]
|
||||||
|
deletedNodeIds: string[]
|
||||||
|
} | null {
|
||||||
|
if (!layout) return null
|
||||||
|
|
||||||
|
const nodeExists = layout.nodes.some((node) => node.id === nodeId)
|
||||||
|
if (!nodeExists) {
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
deletedConnectionIds: [],
|
||||||
|
deletedNodeIds: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childIdsByParent = new Map<string, string[]>()
|
||||||
|
layout.nodes.forEach((node) => {
|
||||||
|
if (!node.parentId) return
|
||||||
|
const childIds = childIdsByParent.get(node.parentId) ?? []
|
||||||
|
childIds.push(node.id)
|
||||||
|
childIdsByParent.set(node.parentId, childIds)
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodes: SessionTreeNodePayload[] = []
|
const deletedNodeIdSet = new Set<string>()
|
||||||
let order = 0
|
const stack = [nodeId]
|
||||||
Array.from(groups.entries()).forEach(([groupName, list]) => {
|
while (stack.length > 0) {
|
||||||
const folderId = `folder-${groupName}-${order}`
|
const currentId = stack.pop()
|
||||||
nodes.push({
|
if (!currentId || deletedNodeIdSet.has(currentId)) continue
|
||||||
id: folderId,
|
deletedNodeIdSet.add(currentId)
|
||||||
type: 'folder',
|
|
||||||
name: groupName,
|
const childIds = childIdsByParent.get(currentId) ?? []
|
||||||
parentId: null,
|
childIds.forEach((childId) => stack.push(childId))
|
||||||
order,
|
}
|
||||||
expanded: true,
|
|
||||||
createdAt: now,
|
const deletedConnectionIds = layout.nodes.flatMap((node) =>
|
||||||
updatedAt: now,
|
deletedNodeIdSet.has(node.id) && node.type === 'connection' && node.connectionId ? [node.connectionId] : [],
|
||||||
})
|
)
|
||||||
list
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
return {
|
||||||
.forEach((connection, index) => {
|
layout: {
|
||||||
nodes.push({
|
...layout,
|
||||||
id: `connection-${connection.id}`,
|
nodes: layout.nodes.filter((node) => !deletedNodeIdSet.has(node.id)),
|
||||||
type: 'connection',
|
sortMode: layout.sortMode ?? 'manual',
|
||||||
name: connection.name,
|
},
|
||||||
parentId: folderId,
|
deletedConnectionIds,
|
||||||
order: index,
|
deletedNodeIds: Array.from(deletedNodeIdSet),
|
||||||
connectionId: connection.id,
|
}
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
order += 1
|
|
||||||
})
|
|
||||||
return { nodes, sortMode: 'manual' }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,106 @@
|
|||||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
Command,
|
Command,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
Folder,
|
||||||
|
HardDrive,
|
||||||
|
ListTree,
|
||||||
LogOut,
|
LogOut,
|
||||||
Monitor,
|
Monitor,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
|
SplitSquareHorizontal,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||||
import { buildDefaultTreeLayout, buildSessionTree, collectConnectionIds, updateExpandedState } from '../lib/utils'
|
import {
|
||||||
import { createConnection, listConnections, updateConnection } from '../services/connections'
|
buildSessionTree,
|
||||||
|
cn,
|
||||||
|
createInitialSessionTreeLayout,
|
||||||
|
deleteSessionTreeNodeSubtree,
|
||||||
|
findConnectionFolderId,
|
||||||
|
findConnectionNodeId,
|
||||||
|
formatBytes,
|
||||||
|
insertFolderNode,
|
||||||
|
listSessionFolderOptions,
|
||||||
|
renameSessionTreeNode,
|
||||||
|
resolveSuggestedFolderId,
|
||||||
|
syncSessionTreeLayout,
|
||||||
|
updateAllFoldersExpandedState,
|
||||||
|
updateExpandedState,
|
||||||
|
upsertConnectionNode,
|
||||||
|
} from '../lib/utils'
|
||||||
|
import { checkConnectionStatuses, createConnection, deleteConnection, listConnections, updateConnection } from '../services/connections'
|
||||||
|
import { getMetrics } from '../services/monitor'
|
||||||
import { getSessionTree, saveSessionTree } from '../services/sessionTree'
|
import { getSessionTree, saveSessionTree } from '../services/sessionTree'
|
||||||
import type { Connection, ConnectionCreateRequest, SessionTreeLayoutPayload, TransferTaskGroup, WorkspaceLayout, WorkspaceTab } from '../types'
|
import type {
|
||||||
|
Connection,
|
||||||
|
ConnectionModalSubmitPayload,
|
||||||
|
ConnectionReachabilityStatus,
|
||||||
|
ConnectionStatusItem,
|
||||||
|
MonitorMetrics,
|
||||||
|
SessionTreeLayoutPayload,
|
||||||
|
TerminalConnectionStatus,
|
||||||
|
TransferTaskGroup,
|
||||||
|
WorkspaceLayout,
|
||||||
|
WorkspaceTab,
|
||||||
|
} from '../types'
|
||||||
import BatchCommandModal from '../components/BatchCommandModal'
|
import BatchCommandModal from '../components/BatchCommandModal'
|
||||||
import ChangePasswordModal from '../components/ChangePasswordModal'
|
import ChangePasswordModal from '../components/ChangePasswordModal'
|
||||||
import ConnectionModal from '../components/ConnectionModal'
|
import ConnectionModal from '../components/ConnectionModal'
|
||||||
|
import FolderModal from '../components/FolderModal'
|
||||||
import SessionTree from '../components/SessionTree'
|
import SessionTree from '../components/SessionTree'
|
||||||
import SettingsModal from '../components/SettingsModal'
|
import SettingsModal from '../components/SettingsModal'
|
||||||
import SftpPane from '../components/SftpPane'
|
import SftpPane from '../components/SftpPane'
|
||||||
import TerminalPane from '../components/TerminalPane'
|
import TerminalPane from '../components/TerminalPane'
|
||||||
import TransferCenterModal from '../components/TransferCenterModal'
|
import TransferCenterModal from '../components/TransferCenterModal'
|
||||||
|
|
||||||
|
const terminalStatusCopy: Record<TerminalConnectionStatus, { label: string; tone: string; dot: string }> = {
|
||||||
|
idle: { label: '终端未打开', tone: 'text-slate-400', dot: 'bg-slate-500' },
|
||||||
|
connecting: { label: 'WebSocket 连接中', tone: 'text-amber-300', dot: 'bg-amber-400' },
|
||||||
|
connected: { label: 'WebSocket 已连接', tone: 'text-emerald-300', dot: 'bg-emerald-400' },
|
||||||
|
reconnecting: { label: '连接中断,重试中', tone: 'text-amber-300', dot: 'bg-amber-400' },
|
||||||
|
error: { label: 'WebSocket 连接异常', tone: 'text-red-300', dot: 'bg-red-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
type TreeContextMenuTargetType = 'folder' | 'connection'
|
||||||
|
|
||||||
|
type TreeContextMenuState = {
|
||||||
|
visible: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
targetId: string | null
|
||||||
|
targetType: TreeContextMenuTargetType | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const closedTreeContextMenu: TreeContextMenuState = {
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
targetId: null,
|
||||||
|
targetType: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClampedContextMenuPosition(x: number, y: number, itemCount: number) {
|
||||||
|
const menuWidth = 176
|
||||||
|
const menuHeight = itemCount * 36 + 8
|
||||||
|
const viewportPadding = 8
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(viewportPadding, Math.min(x, window.innerWidth - menuWidth - viewportPadding)),
|
||||||
|
y: Math.max(viewportPadding, Math.min(y, window.innerHeight - menuHeight - viewportPadding)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitConnectionIdsFromRecord<T>(record: Record<number, T>, deletedConnectionIds: Set<number>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(record).filter(([key]) => !deletedConnectionIds.has(Number(key))),
|
||||||
|
) as Record<number, T>
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkspacePage({
|
export default function WorkspacePage({
|
||||||
initialTool,
|
initialTool,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -38,14 +115,24 @@ export default function WorkspacePage({
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [tabs, setTabs] = useState<WorkspaceTab[]>([])
|
const [tabs, setTabs] = useState<WorkspaceTab[]>([])
|
||||||
const [currentTabId, setCurrentTabId] = useState<number | null>(null)
|
const [currentTabId, setCurrentTabId] = useState<number | null>(null)
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||||
const [showConnectionModal, setShowConnectionModal] = useState(false)
|
const [showConnectionModal, setShowConnectionModal] = useState(false)
|
||||||
|
const [showFolderModal, setShowFolderModal] = useState(false)
|
||||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null)
|
const [editingConnection, setEditingConnection] = useState<Connection | null>(null)
|
||||||
const [showBatchModal, setShowBatchModal] = useState(false)
|
const [showBatchModal, setShowBatchModal] = useState(false)
|
||||||
const [showTransferModal, setShowTransferModal] = useState(initialTool === 'transfers')
|
const [showTransferModal, setShowTransferModal] = useState(initialTool === 'transfers')
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||||
const [showChangePassword, setShowChangePassword] = useState<boolean>(!!user?.passwordChangeRequired)
|
const [showChangePassword, setShowChangePassword] = useState<boolean>(!!user?.passwordChangeRequired)
|
||||||
const [transferTasks, setTransferTasks] = useState<TransferTaskGroup[]>([])
|
const [transferTasks, setTransferTasks] = useState<TransferTaskGroup[]>([])
|
||||||
|
const [workspaceMetrics, setWorkspaceMetrics] = useState<MonitorMetrics>({})
|
||||||
|
const [connectionStatuses, setConnectionStatuses] = useState<Record<number, ConnectionReachabilityStatus>>({})
|
||||||
|
const [connectionStatusDetails, setConnectionStatusDetails] = useState<Record<number, ConnectionStatusItem>>({})
|
||||||
|
const [connectionStatusError, setConnectionStatusError] = useState<string | null>(null)
|
||||||
|
const [connectionStatusLoading, setConnectionStatusLoading] = useState(false)
|
||||||
|
const [terminalStatuses, setTerminalStatuses] = useState<Record<number, TerminalConnectionStatus>>({})
|
||||||
|
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState>(closedTreeContextMenu)
|
||||||
|
const [tabContextMenu, setTabContextMenu] = useState({ visible: false, x: 0, y: 0 })
|
||||||
const [terminalFontSize, setTerminalFontSize] = useLocalStorage('ssh-manager.terminal-font-size', 14)
|
const [terminalFontSize, setTerminalFontSize] = useLocalStorage('ssh-manager.terminal-font-size', 14)
|
||||||
const [terminalFontFamily, setTerminalFontFamily] = useLocalStorage(
|
const [terminalFontFamily, setTerminalFontFamily] = useLocalStorage(
|
||||||
'ssh-manager.terminal-font-family',
|
'ssh-manager.terminal-font-family',
|
||||||
@@ -57,72 +144,415 @@ export default function WorkspacePage({
|
|||||||
}, [user?.passwordChangeRequired])
|
}, [user?.passwordChangeRequired])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const connectionIds = new Set(connections.map((connection) => connection.id))
|
||||||
|
setConnectionStatuses((prev) =>
|
||||||
|
Object.fromEntries(Object.entries(prev).filter(([key]) => connectionIds.has(Number(key)))) as Record<
|
||||||
|
number,
|
||||||
|
ConnectionReachabilityStatus
|
||||||
|
>,
|
||||||
|
)
|
||||||
|
setConnectionStatusDetails((prev) =>
|
||||||
|
Object.fromEntries(Object.entries(prev).filter(([key]) => connectionIds.has(Number(key)))) as Record<
|
||||||
|
number,
|
||||||
|
ConnectionStatusItem
|
||||||
|
>,
|
||||||
|
)
|
||||||
|
}, [connections])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const connectionsResponse = await listConnections()
|
const connectionsResponse = await listConnections()
|
||||||
setConnections(connectionsResponse.data)
|
if (cancelled) return
|
||||||
|
|
||||||
|
const nextConnections = connectionsResponse.data
|
||||||
|
setConnections(nextConnections)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const treeResponse = await getSessionTree()
|
const treeResponse = await getSessionTree()
|
||||||
if (treeResponse.data.nodes.length) {
|
if (cancelled) return
|
||||||
setTreeLayout(treeResponse.data)
|
|
||||||
} else {
|
const baseLayout = treeResponse.data.nodes.length
|
||||||
const layout = buildDefaultTreeLayout(connectionsResponse.data)
|
? treeResponse.data
|
||||||
setTreeLayout(layout)
|
: createInitialSessionTreeLayout(nextConnections)
|
||||||
await saveSessionTree(layout)
|
const nextLayout = syncSessionTreeLayout(baseLayout, nextConnections)
|
||||||
|
setTreeLayout(nextLayout)
|
||||||
|
|
||||||
|
if (JSON.stringify(treeResponse.data) !== JSON.stringify(nextLayout)) {
|
||||||
|
await saveSessionTree(nextLayout)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const layout = buildDefaultTreeLayout(connectionsResponse.data)
|
if (cancelled) return
|
||||||
setTreeLayout(layout)
|
setTreeLayout(createInitialSessionTreeLayout(nextConnections))
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const treeNodes = useMemo(() => buildSessionTree(treeLayout, connections), [treeLayout, connections])
|
const treeNodes = useMemo(() => buildSessionTree(treeLayout, connections), [treeLayout, connections])
|
||||||
|
const folderOptions = useMemo(() => listSessionFolderOptions(treeLayout, connections), [treeLayout, connections])
|
||||||
|
const openConnectionIds = useMemo(() => tabs.map((tab) => tab.id), [tabs])
|
||||||
const activeConnection = tabs.find((tab) => tab.id === currentTabId)?.connection ?? null
|
const activeConnection = tabs.find((tab) => tab.id === currentTabId)?.connection ?? null
|
||||||
|
const activeTerminalStatus = activeConnection ? terminalStatuses[activeConnection.id] ?? 'connecting' : 'idle'
|
||||||
|
const hasFolders = treeLayout?.nodes.some((node) => node.type === 'folder') ?? false
|
||||||
|
const hasCollapsedFolders = treeLayout?.nodes.some((node) => node.type === 'folder' && node.expanded === false) ?? false
|
||||||
|
const connectionModalFolderId = useMemo(
|
||||||
|
() =>
|
||||||
|
editingConnection
|
||||||
|
? findConnectionFolderId(treeLayout, editingConnection.id)
|
||||||
|
: resolveSuggestedFolderId(treeLayout, selectedNodeId),
|
||||||
|
[editingConnection, selectedNodeId, treeLayout],
|
||||||
|
)
|
||||||
|
|
||||||
function openConnection(connection: Connection) {
|
useEffect(() => {
|
||||||
|
if (!activeConnection) {
|
||||||
|
setWorkspaceMetrics({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getMetrics(activeConnection.id)
|
||||||
|
if (!cancelled) {
|
||||||
|
setWorkspaceMetrics(response.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setWorkspaceMetrics({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchMetrics()
|
||||||
|
const timer = window.setInterval(fetchMetrics, 5000)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [activeConnection?.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCachedReachability = connections.some((connection) => {
|
||||||
|
const status = connectionStatuses[connection.id]
|
||||||
|
return status === 'online' || status === 'offline' || status === 'checking'
|
||||||
|
})
|
||||||
|
if (hasCachedReachability) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshConnectionStatuses({ connectionList: connections })
|
||||||
|
}, [connections])
|
||||||
|
|
||||||
|
function getCurrentTreeLayout(nextConnections = connections) {
|
||||||
|
return syncSessionTreeLayout(treeLayout ?? createInitialSessionTreeLayout(nextConnections), nextConnections)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshConnectionStatuses(options?: {
|
||||||
|
connectionList?: Connection[]
|
||||||
|
throwOnError?: boolean
|
||||||
|
}) {
|
||||||
|
const connectionList = options?.connectionList ?? connections
|
||||||
|
if (connectionList.length === 0) {
|
||||||
|
setConnectionStatuses({})
|
||||||
|
setConnectionStatusDetails({})
|
||||||
|
setConnectionStatusError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionStatusLoading(true)
|
||||||
|
setConnectionStatusError(null)
|
||||||
|
const connectionIds = connectionList.map((connection) => connection.id)
|
||||||
|
setConnectionStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...Object.fromEntries(connectionIds.map((id) => [id, 'checking' as ConnectionReachabilityStatus])),
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await checkConnectionStatuses(connectionIds)
|
||||||
|
const nextDetails = Object.fromEntries(
|
||||||
|
response.data.results.map((item) => [item.connectionId, item]),
|
||||||
|
) as Record<number, ConnectionStatusItem>
|
||||||
|
const nextStatuses = Object.fromEntries(
|
||||||
|
response.data.results.map((item) => [item.connectionId, item.status as ConnectionReachabilityStatus]),
|
||||||
|
) as Record<number, ConnectionReachabilityStatus>
|
||||||
|
|
||||||
|
setConnectionStatusDetails((prev) => ({ ...prev, ...nextDetails }))
|
||||||
|
setConnectionStatuses((prev) => ({ ...prev, ...nextStatuses }))
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionStatusError(
|
||||||
|
(error as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message ||
|
||||||
|
'主机状态检测失败',
|
||||||
|
)
|
||||||
|
setConnectionStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...Object.fromEntries(connectionIds.map((id) => [id, prev[id] === 'online' || prev[id] === 'offline' ? prev[id] : 'unknown'])),
|
||||||
|
}))
|
||||||
|
if (options?.throwOnError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConnectionStatusLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshConnectionStatuses() {
|
||||||
|
await refreshConnectionStatuses({ throwOnError: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistTreeLayout(nextLayout: SessionTreeLayoutPayload) {
|
||||||
|
setTreeLayout(nextLayout)
|
||||||
|
await saveSessionTree(nextLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTreeNode(nodeId: string) {
|
||||||
|
return treeLayout?.nodes.find((node) => node.id === nodeId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTreeContextMenu() {
|
||||||
|
setTreeContextMenu(closedTreeContextMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConnection(connection: Connection, nodeId?: string | null) {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
setTerminalStatuses((prev) => (prev[connection.id] ? prev : { ...prev, [connection.id]: 'connecting' }))
|
||||||
setTabs((prev) => {
|
setTabs((prev) => {
|
||||||
if (prev.some((tab) => tab.id === connection.id)) return prev
|
const existing = prev.find((tab) => tab.id === connection.id)
|
||||||
|
if (existing) {
|
||||||
|
return prev.map((tab) =>
|
||||||
|
tab.id === connection.id ? { ...tab, name: connection.name, connection } : tab,
|
||||||
|
)
|
||||||
|
}
|
||||||
return [...prev, { id: connection.id, name: connection.name, connection }]
|
return [...prev, { id: connection.id, name: connection.name, connection }]
|
||||||
})
|
})
|
||||||
setCurrentTabId(connection.id)
|
setCurrentTabId(connection.id)
|
||||||
|
setSelectedFiles([])
|
||||||
|
if (nodeId) {
|
||||||
|
setSelectedNodeId(nodeId)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleFolder(nodeId: string) {
|
async function handleToggleFolder(nodeId: string) {
|
||||||
const nextLayout = updateExpandedState(treeLayout, nodeId)
|
const nextLayout = updateExpandedState(treeLayout, nodeId)
|
||||||
setTreeLayout(nextLayout)
|
if (!nextLayout) return
|
||||||
if (nextLayout) {
|
await persistTreeLayout(nextLayout)
|
||||||
await saveSessionTree(nextLayout)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitConnection(payload: ConnectionCreateRequest) {
|
async function handleToggleAllFolders() {
|
||||||
if (editingConnection) {
|
const baseLayout = getCurrentTreeLayout()
|
||||||
await updateConnection(editingConnection.id, payload)
|
const hasAnyFolder = baseLayout.nodes.some((node) => node.type === 'folder')
|
||||||
} else {
|
if (!hasAnyFolder) return
|
||||||
await createConnection(payload)
|
|
||||||
|
const shouldExpand = baseLayout.nodes.some((node) => node.type === 'folder' && node.expanded === false)
|
||||||
|
const nextLayout = updateAllFoldersExpandedState(baseLayout, shouldExpand)
|
||||||
|
if (!nextLayout) return
|
||||||
|
|
||||||
|
await persistTreeLayout(nextLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateFolder(payload: { name: string; parentId: string | null }) {
|
||||||
|
const baseLayout = getCurrentTreeLayout()
|
||||||
|
const { layout: nextLayout, nodeId } = insertFolderNode(baseLayout, payload.name, payload.parentId)
|
||||||
|
setSelectedNodeId(nodeId)
|
||||||
|
await persistTreeLayout(nextLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenTreeContextMenu(payload: { nodeId: string; nodeType: TreeContextMenuTargetType; x: number; y: number }) {
|
||||||
|
const position = getClampedContextMenuPosition(payload.x, payload.y, payload.nodeType === 'folder' ? 4 : 2)
|
||||||
|
setSelectedNodeId(payload.nodeId)
|
||||||
|
closeTabContextMenu()
|
||||||
|
setTreeContextMenu({
|
||||||
|
visible: true,
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
targetId: payload.nodeId,
|
||||||
|
targetType: payload.nodeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAncestorNode(layout: SessionTreeLayoutPayload, ancestorNodeId: string, nodeId: string) {
|
||||||
|
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]))
|
||||||
|
let currentNode = nodesById.get(nodeId) ?? null
|
||||||
|
|
||||||
|
while (currentNode?.parentId) {
|
||||||
|
if (currentNode.parentId === ancestorNodeId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
currentNode = nodesById.get(currentNode.parentId) ?? null
|
||||||
}
|
}
|
||||||
const response = await listConnections()
|
|
||||||
setConnections(response.data)
|
return false
|
||||||
if (!treeLayout || collectConnectionIds(buildSessionTree(treeLayout, response.data)).length !== response.data.length) {
|
}
|
||||||
const nextLayout = buildDefaultTreeLayout(response.data)
|
|
||||||
setTreeLayout(nextLayout)
|
function clearSelectedNodeIfNeeded(layout: SessionTreeLayoutPayload, targetId: string, deletedNodeIds: string[]) {
|
||||||
await saveSessionTree(nextLayout)
|
const deletedNodeIdSet = new Set(deletedNodeIds)
|
||||||
|
setSelectedNodeId((current) => {
|
||||||
|
if (!current) return current
|
||||||
|
if (deletedNodeIdSet.has(current)) return null
|
||||||
|
return isAncestorNode(layout, current, targetId) ? null : current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConnectionsFromWorkspace(connectionIds: number[]) {
|
||||||
|
if (connectionIds.length === 0) return
|
||||||
|
|
||||||
|
const deletedConnectionIds = new Set(connectionIds)
|
||||||
|
setConnections((prev) => prev.filter((connection) => !deletedConnectionIds.has(connection.id)))
|
||||||
|
setTabs((prev) => {
|
||||||
|
const next = prev.filter((tab) => !deletedConnectionIds.has(tab.id))
|
||||||
|
setCurrentTabId((current) =>
|
||||||
|
current != null && deletedConnectionIds.has(current) ? next[next.length - 1]?.id ?? null : current,
|
||||||
|
)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setSelectedFiles([])
|
||||||
|
setConnectionStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||||
|
setConnectionStatusDetails((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||||
|
setTerminalStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditTreeItem() {
|
||||||
|
const { targetId, targetType } = treeContextMenu
|
||||||
|
closeTreeContextMenu()
|
||||||
|
if (!targetId || !targetType) return
|
||||||
|
|
||||||
|
if (targetType === 'folder') {
|
||||||
|
const targetNode = findTreeNode(targetId)
|
||||||
|
if (!targetNode || targetNode.type !== 'folder') return
|
||||||
|
|
||||||
|
const nextName = window.prompt('重命名文件夹', targetNode.name)
|
||||||
|
const trimmedName = nextName?.trim()
|
||||||
|
if (!trimmedName || trimmedName === targetNode.name) return
|
||||||
|
|
||||||
|
const nextLayout = renameSessionTreeNode(getCurrentTreeLayout(), targetId, trimmedName)
|
||||||
|
if (!nextLayout) return
|
||||||
|
await persistTreeLayout(nextLayout)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetNode = findTreeNode(targetId)
|
||||||
|
if (targetNode?.type !== 'connection' || !targetNode.connectionId) return
|
||||||
|
|
||||||
|
const targetConnection = connections.find((connection) => connection.id === targetNode.connectionId) ?? null
|
||||||
|
if (!targetConnection) return
|
||||||
|
|
||||||
|
setEditingConnection(targetConnection)
|
||||||
|
setShowConnectionModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteTreeItem() {
|
||||||
|
const { targetId, targetType } = treeContextMenu
|
||||||
|
closeTreeContextMenu()
|
||||||
|
if (!targetId || !targetType) return
|
||||||
|
|
||||||
|
const baseLayout = getCurrentTreeLayout()
|
||||||
|
const targetNode = baseLayout.nodes.find((node) => node.id === targetId)
|
||||||
|
if (!targetNode) return
|
||||||
|
|
||||||
|
const deletedSubtree = deleteSessionTreeNodeSubtree(baseLayout, targetId)
|
||||||
|
if (!deletedSubtree) return
|
||||||
|
|
||||||
|
if (targetType === 'connection') {
|
||||||
|
if (targetNode.type !== 'connection' || !targetNode.connectionId) return
|
||||||
|
const confirmed = window.confirm('确认永久删除该 SSH 连接?')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
await deleteConnection(targetNode.connectionId)
|
||||||
|
await persistTreeLayout(deletedSubtree.layout)
|
||||||
|
removeConnectionsFromWorkspace([targetNode.connectionId])
|
||||||
|
clearSelectedNodeIfNeeded(baseLayout, targetId, deletedSubtree.deletedNodeIds)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm('确认删除该文件夹?将同时删除其下所有子文件夹与连接。')
|
||||||
|
if (!confirmed) return
|
||||||
|
if (targetNode.type !== 'folder') return
|
||||||
|
|
||||||
|
if (deletedSubtree.deletedConnectionIds.length > 0) {
|
||||||
|
await Promise.all(deletedSubtree.deletedConnectionIds.map((connectionId) => deleteConnection(connectionId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistTreeLayout(deletedSubtree.layout)
|
||||||
|
removeConnectionsFromWorkspace(deletedSubtree.deletedConnectionIds)
|
||||||
|
clearSelectedNodeIfNeeded(baseLayout, targetId, deletedSubtree.deletedNodeIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitConnection(payload: ConnectionModalSubmitPayload) {
|
||||||
|
const connectionToEdit = editingConnection
|
||||||
|
const { targetFolderId, ...request } = payload
|
||||||
|
const connectionResponse = connectionToEdit ? await updateConnection(connectionToEdit.id, request) : await createConnection(request)
|
||||||
|
|
||||||
|
const savedConnection = connectionResponse.data
|
||||||
|
const connectionsResponse = await listConnections()
|
||||||
|
const nextConnections = connectionsResponse.data
|
||||||
|
const nextConnection = nextConnections.find((connection) => connection.id === savedConnection.id) ?? savedConnection
|
||||||
|
setConnections(nextConnections)
|
||||||
|
|
||||||
|
const nextLayout = upsertConnectionNode(getCurrentTreeLayout(nextConnections), nextConnection, targetFolderId)
|
||||||
|
const nodeId = findConnectionNodeId(nextLayout, nextConnection.id)
|
||||||
|
|
||||||
|
await persistTreeLayout(nextLayout)
|
||||||
|
setShowConnectionModal(false)
|
||||||
setEditingConnection(null)
|
setEditingConnection(null)
|
||||||
|
|
||||||
|
if (connectionToEdit) {
|
||||||
|
setSelectedNodeId(nodeId)
|
||||||
|
setTabs((prev) =>
|
||||||
|
prev.map((tab) => (tab.id === nextConnection.id ? { ...tab, name: nextConnection.name, connection: nextConnection } : tab)),
|
||||||
|
)
|
||||||
|
setConnectionStatusDetails((prev) =>
|
||||||
|
prev[nextConnection.id]
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
[nextConnection.id]: {
|
||||||
|
...prev[nextConnection.id],
|
||||||
|
connectionName: nextConnection.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openConnection(nextConnection, nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCloseTab(id: number) {
|
function handleCloseTab(id: number) {
|
||||||
setTabs((prev) => {
|
setTabs((prev) => {
|
||||||
const next = prev.filter((tab) => tab.id !== id)
|
const next = prev.filter((tab) => tab.id !== id)
|
||||||
if (currentTabId === id) {
|
setCurrentTabId((current) => (current === id ? next[next.length - 1]?.id ?? null : current))
|
||||||
setCurrentTabId(next[next.length - 1]?.id ?? null)
|
return next
|
||||||
}
|
})
|
||||||
|
setTerminalStatuses((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[id]
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTabContextMenu() {
|
||||||
|
setTabContextMenu({ visible: false, x: 0, y: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllTabs() {
|
||||||
|
setTabs([])
|
||||||
|
setCurrentTabId(null)
|
||||||
|
setTerminalStatuses({})
|
||||||
|
setSelectedFiles([])
|
||||||
|
closeTabContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMeta = terminalStatusCopy[activeTerminalStatus]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-slate-950 text-slate-100">
|
<div className="flex h-screen flex-col overflow-hidden bg-slate-950 text-slate-100">
|
||||||
<header className="flex h-14 items-center justify-between border-b border-slate-800 bg-slate-900 px-4 shadow-sm">
|
<header className="flex h-14 items-center justify-between border-b border-slate-800 bg-slate-900 px-4 shadow-sm">
|
||||||
@@ -135,15 +565,17 @@ export default function WorkspacePage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-6 w-px bg-slate-800" />
|
<div className="h-6 w-px bg-slate-800" />
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowConnectionModal(true)}>
|
<button
|
||||||
<Plus size={16} className="text-emerald-500" />
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
|
||||||
新建连接
|
onClick={() => setShowBatchModal(true)}
|
||||||
</button>
|
>
|
||||||
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowBatchModal(true)}>
|
|
||||||
<Command size={16} className="text-purple-400" />
|
<Command size={16} className="text-purple-400" />
|
||||||
批量执行
|
批量执行
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowTransferModal(true)}>
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => setShowTransferModal(true)}
|
||||||
|
>
|
||||||
<FileUp size={16} className="text-blue-400" />
|
<FileUp size={16} className="text-blue-400" />
|
||||||
传输中心
|
传输中心
|
||||||
</button>
|
</button>
|
||||||
@@ -159,10 +591,15 @@ export default function WorkspacePage({
|
|||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="rounded-full p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowSettingsModal(true)}>
|
<button
|
||||||
|
className="rounded-full p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => setShowSettingsModal(true)}
|
||||||
|
>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div className="rounded-full border border-slate-800 bg-slate-950 px-3 py-1.5 text-sm text-slate-400">{user?.displayName || user?.username}</div>
|
<div className="rounded-full border border-slate-800 bg-slate-950 px-3 py-1.5 text-sm text-slate-400">
|
||||||
|
{user?.displayName || user?.username}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-slate-400 transition hover:text-red-400"
|
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-slate-400 transition hover:text-red-400"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -178,63 +615,209 @@ export default function WorkspacePage({
|
|||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-800 bg-slate-900">
|
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-800 bg-slate-900">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm">
|
<div className="border-b border-slate-800 px-4 py-3">
|
||||||
<span className="font-medium text-slate-300">会话管理</span>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<button className="rounded-lg p-1 text-slate-400 transition hover:bg-slate-800" onClick={() => setShowConnectionModal(true)}>
|
<div className="text-sm font-medium text-slate-300">会话管理</div>
|
||||||
<Plus size={14} />
|
<div className="flex items-center gap-1">
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white',
|
||||||
|
hasFolders ? '' : 'cursor-not-allowed text-slate-600 hover:bg-transparent hover:text-slate-600',
|
||||||
|
)}
|
||||||
|
disabled={!hasFolders}
|
||||||
|
onClick={() => void handleToggleAllFolders()}
|
||||||
|
title={hasFolders ? (hasCollapsedFolders ? '全部展开' : '全部折叠') : '暂无文件夹'}
|
||||||
|
aria-label={hasFolders ? (hasCollapsedFolders ? '全部展开' : '全部折叠') : '暂无文件夹'}
|
||||||
|
>
|
||||||
|
<ListTree size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
closeTreeContextMenu()
|
||||||
|
setShowFolderModal(true)
|
||||||
|
}}
|
||||||
|
title="新建文件夹"
|
||||||
|
aria-label="新建文件夹"
|
||||||
|
>
|
||||||
|
<Folder size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
closeTreeContextMenu()
|
||||||
|
setEditingConnection(null)
|
||||||
|
setShowConnectionModal(true)
|
||||||
|
}}
|
||||||
|
title="新建连接"
|
||||||
|
aria-label="新建连接"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-2">
|
<div
|
||||||
<SessionTree nodes={treeNodes} activeConnectionId={currentTabId} search={search} onToggleFolder={(nodeId) => void handleToggleFolder(nodeId)} onOpenConnection={openConnection} />
|
className="flex-1 overflow-auto p-2"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return
|
||||||
|
closeTreeContextMenu()
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SessionTree
|
||||||
|
nodes={treeNodes}
|
||||||
|
activeConnectionId={currentTabId}
|
||||||
|
connectionStatuses={connectionStatuses}
|
||||||
|
openConnectionIds={openConnectionIds}
|
||||||
|
selectedNodeId={selectedNodeId}
|
||||||
|
search={search}
|
||||||
|
onSelectNode={setSelectedNodeId}
|
||||||
|
onToggleFolder={(nodeId) => void handleToggleFolder(nodeId)}
|
||||||
|
onOpenConnection={openConnection}
|
||||||
|
onContextMenu={handleOpenTreeContextMenu}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex flex-1 flex-col overflow-hidden bg-[#0d1117]">
|
<main className="flex flex-1 flex-col overflow-hidden bg-[#0d1117]">
|
||||||
<div className="flex border-b border-slate-800 bg-slate-900">
|
<div className="flex h-10 border-b border-slate-800 bg-slate-900">
|
||||||
{tabs.length === 0 ? <div className="h-10" /> : null}
|
{tabs.length === 0 ? <div className="h-full flex-1" /> : null}
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`group flex h-10 min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm ${currentTabId === tab.id ? 'border-t-2 border-t-blue-500 bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800'}`}
|
className={cn(
|
||||||
|
'group flex h-full min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm transition',
|
||||||
|
currentTabId === tab.id ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
||||||
|
)}
|
||||||
onClick={() => setCurrentTabId(tab.id)}
|
onClick={() => setCurrentTabId(tab.id)}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
closeTreeContextMenu()
|
||||||
|
setTabContextMenu({ visible: true, x: event.clientX, y: event.clientY })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : ''} />
|
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : 'text-slate-500'} />
|
||||||
<span className="flex-1 truncate">{tab.name}</span>
|
<span className="flex-1 truncate">{tab.name}</span>
|
||||||
<span className="opacity-0 transition group-hover:opacity-100" onClick={(event) => { event.stopPropagation(); handleCloseTab(tab.id) }}>
|
<span
|
||||||
|
className="opacity-0 transition group-hover:opacity-100"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleCloseTab(tab.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-11 items-center justify-between border-b border-slate-800 bg-slate-800/80 px-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-4 overflow-hidden text-xs">
|
||||||
|
<div className="flex items-center gap-2 text-slate-300">
|
||||||
|
<Activity size={14} className="text-emerald-400" />
|
||||||
|
CPU: {workspaceMetrics.cpuUsage ?? '-'}%
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-300">
|
||||||
|
<HardDrive size={14} className="text-blue-400" />
|
||||||
|
MEM: {formatBytes(workspaceMetrics.memUsed ?? null)} / {formatBytes(workspaceMetrics.memTotal ?? null)}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex items-center gap-2 truncate', statusMeta.tone)}>
|
||||||
|
<span className={cn('h-2 w-2 rounded-full', statusMeta.dot)} />
|
||||||
|
{statusMeta.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-3 flex items-center rounded-lg border border-slate-700 bg-slate-900/90 p-1">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-2 transition',
|
||||||
|
layout === 'terminal' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
||||||
|
)}
|
||||||
|
onClick={() => setLayout('terminal')}
|
||||||
|
title="终端"
|
||||||
|
>
|
||||||
|
<Terminal size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-2 transition',
|
||||||
|
layout === 'sftp' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
||||||
|
)}
|
||||||
|
onClick={() => setLayout('sftp')}
|
||||||
|
title="SFTP"
|
||||||
|
>
|
||||||
|
<Folder size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-2 transition',
|
||||||
|
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
||||||
|
)}
|
||||||
|
onClick={() => setLayout('split')}
|
||||||
|
title="分屏"
|
||||||
|
>
|
||||||
|
<SplitSquareHorizontal size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
{!activeConnection ? (
|
{!activeConnection ? (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-500">
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-500">
|
||||||
<Monitor size={64} className="mb-4 text-slate-800" />
|
<Monitor size={64} className="mb-4 text-slate-800" />
|
||||||
<h2 className="mb-2 text-xl font-medium text-slate-300">欢迎使用 SSH Manager</h2>
|
<h2 className="mb-2 text-xl font-medium text-slate-300">欢迎使用 SSH Manager</h2>
|
||||||
<p className="text-sm">请在左侧点击服务器建立连接,或创建新的会话。</p>
|
<p className="text-sm">请在左侧双击服务器节点建立连接,或先创建新的会话。</p>
|
||||||
<button className="mt-6 flex items-center gap-2 rounded-xl border border-blue-600/30 bg-blue-600/10 px-6 py-2 text-blue-300 transition hover:bg-blue-600/20" onClick={() => setShowConnectionModal(true)}>
|
<button
|
||||||
|
className="mt-6 flex items-center gap-2 rounded-xl border border-blue-600/30 bg-blue-600/10 px-6 py-2 text-blue-300 transition hover:bg-blue-600/20"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingConnection(null)
|
||||||
|
setShowConnectionModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
创建新连接
|
创建新连接
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`flex h-full ${layout === 'split' ? 'flex-row' : 'flex-col'}`}>
|
<div className={`flex h-full min-w-0 ${layout === 'split' ? 'flex-row' : 'flex-col'}`}>
|
||||||
{(layout === 'split' || layout === 'terminal') && (
|
<div
|
||||||
<div className={`${layout === 'split' ? 'w-1/2 border-r border-slate-800' : 'w-full'} overflow-hidden`}>
|
className={cn(
|
||||||
<TerminalPane
|
'relative min-w-0 flex-1 overflow-hidden',
|
||||||
connection={activeConnection}
|
layout === 'split' && 'w-1/2 border-r border-slate-800',
|
||||||
active
|
layout === 'terminal' && 'w-full',
|
||||||
layout={layout}
|
layout === 'sftp' && 'hidden',
|
||||||
onLayoutChange={setLayout}
|
)}
|
||||||
fontSize={terminalFontSize}
|
>
|
||||||
fontFamily={terminalFontFamily}
|
{tabs.map((tab) => {
|
||||||
/>
|
const visible = tab.id === currentTabId && layout !== 'sftp'
|
||||||
</div>
|
return (
|
||||||
)}
|
<div key={tab.id} className={cn('absolute inset-0', !visible && 'hidden')}>
|
||||||
|
<TerminalPane
|
||||||
|
connection={tab.connection}
|
||||||
|
visible={visible}
|
||||||
|
fontSize={terminalFontSize}
|
||||||
|
fontFamily={terminalFontFamily}
|
||||||
|
onStatusChange={(status) => {
|
||||||
|
setTerminalStatuses((prev) =>
|
||||||
|
prev[tab.id] === status ? prev : { ...prev, [tab.id]: status },
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
{(layout === 'split' || layout === 'sftp') && (
|
{(layout === 'split' || layout === 'sftp') && (
|
||||||
<div className={`${layout === 'split' ? 'w-1/2' : 'w-full'} overflow-hidden`}>
|
<div className={`${layout === 'split' ? 'min-w-0 w-1/2' : 'w-full'} overflow-hidden`}>
|
||||||
<SftpPane connection={activeConnection} selectedFiles={selectedFiles} onSelectedFilesChange={setSelectedFiles} />
|
<SftpPane
|
||||||
|
connection={activeConnection}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onSelectedFilesChange={setSelectedFiles}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -246,13 +829,31 @@ export default function WorkspacePage({
|
|||||||
<ConnectionModal
|
<ConnectionModal
|
||||||
open={showConnectionModal}
|
open={showConnectionModal}
|
||||||
connection={editingConnection}
|
connection={editingConnection}
|
||||||
|
folderOptions={folderOptions}
|
||||||
|
initialTargetFolderId={connectionModalFolderId}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowConnectionModal(false)
|
setShowConnectionModal(false)
|
||||||
setEditingConnection(null)
|
setEditingConnection(null)
|
||||||
}}
|
}}
|
||||||
onSubmit={handleSubmitConnection}
|
onSubmit={handleSubmitConnection}
|
||||||
/>
|
/>
|
||||||
<BatchCommandModal open={showBatchModal} connections={connections} onClose={() => setShowBatchModal(false)} />
|
<FolderModal
|
||||||
|
open={showFolderModal}
|
||||||
|
folderOptions={folderOptions}
|
||||||
|
initialParentId={resolveSuggestedFolderId(treeLayout, selectedNodeId)}
|
||||||
|
onClose={() => setShowFolderModal(false)}
|
||||||
|
onSubmit={handleCreateFolder}
|
||||||
|
/>
|
||||||
|
<BatchCommandModal
|
||||||
|
open={showBatchModal}
|
||||||
|
connections={connections}
|
||||||
|
connectionStatuses={connectionStatuses}
|
||||||
|
connectionStatusDetails={connectionStatusDetails}
|
||||||
|
statusError={connectionStatusError}
|
||||||
|
statusLoading={connectionStatusLoading}
|
||||||
|
onRefreshStatuses={handleRefreshConnectionStatuses}
|
||||||
|
onClose={() => setShowBatchModal(false)}
|
||||||
|
/>
|
||||||
<TransferCenterModal
|
<TransferCenterModal
|
||||||
open={showTransferModal}
|
open={showTransferModal}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
@@ -269,6 +870,67 @@ export default function WorkspacePage({
|
|||||||
onFontFamilyChange={setTerminalFontFamily}
|
onFontFamilyChange={setTerminalFontFamily}
|
||||||
/>
|
/>
|
||||||
{showChangePassword ? <ChangePasswordModal force={!!user?.passwordChangeRequired} onClose={() => setShowChangePassword(false)} /> : null}
|
{showChangePassword ? <ChangePasswordModal force={!!user?.passwordChangeRequired} onClose={() => setShowChangePassword(false)} /> : null}
|
||||||
|
{treeContextMenu.visible ? (
|
||||||
|
<div className="fixed inset-0 z-50" onClick={closeTreeContextMenu}>
|
||||||
|
<div
|
||||||
|
className="absolute min-w-44 rounded-xl border border-slate-700 bg-slate-900 p-1 shadow-2xl shadow-black/40"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{ left: treeContextMenu.x, top: treeContextMenu.y }}
|
||||||
|
>
|
||||||
|
{treeContextMenu.targetType === 'folder' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
closeTreeContextMenu()
|
||||||
|
setEditingConnection(null)
|
||||||
|
setShowConnectionModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建连接
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
closeTreeContextMenu()
|
||||||
|
setShowFolderModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建文件夹
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => void handleEditTreeItem()}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-red-300 transition hover:bg-red-500/10 hover:text-red-200"
|
||||||
|
onClick={() => void handleDeleteTreeItem()}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{tabContextMenu.visible ? (
|
||||||
|
<div className="fixed inset-0 z-50" onClick={closeTabContextMenu}>
|
||||||
|
<div
|
||||||
|
className="absolute min-w-40 rounded-xl border border-slate-700 bg-slate-900 p-1 shadow-2xl shadow-black/40"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{ left: tabContextMenu.x, top: tabContextMenu.y }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={closeAllTabs}
|
||||||
|
>
|
||||||
|
关闭所有标签
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import http from './http'
|
import http from './http'
|
||||||
import type { BatchCommandResponse, Connection, ConnectionCreateRequest } from '../types'
|
import type { BatchCommandResponse, Connection, ConnectionCreateRequest, ConnectionStatusResponse } from '../types'
|
||||||
|
|
||||||
export function listConnections() {
|
export function listConnections() {
|
||||||
return http.get<Connection[]>('/connections')
|
return http.get<Connection[]>('/connections')
|
||||||
@@ -20,3 +20,7 @@ export function deleteConnection(id: number) {
|
|||||||
export function executeBatchCommand(connectionIds: number[], command: string) {
|
export function executeBatchCommand(connectionIds: number[], command: string) {
|
||||||
return http.post<BatchCommandResponse>('/connections/batch-command', { connectionIds, command })
|
return http.post<BatchCommandResponse>('/connections/batch-command', { connectionIds, command })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkConnectionStatuses(connectionIds: number[]) {
|
||||||
|
return http.post<ConnectionStatusResponse>('/connections/status', { connectionIds })
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type ConnectionSetupMode = 'NONE' | 'PASSWORD_BOOTSTRAP'
|
|||||||
export type SessionTreeNodeType = 'folder' | 'connection'
|
export type SessionTreeNodeType = 'folder' | 'connection'
|
||||||
export type SessionTreeSortMode = 'manual' | 'nameAsc'
|
export type SessionTreeSortMode = 'manual' | 'nameAsc'
|
||||||
export type WorkspaceLayout = 'split' | 'terminal' | 'sftp'
|
export type WorkspaceLayout = 'split' | 'terminal' | 'sftp'
|
||||||
|
export type TerminalConnectionStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'
|
||||||
|
export type ConnectionReachabilityStatus = 'unknown' | 'checking' | 'online' | 'offline'
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
token: string
|
token: string
|
||||||
@@ -41,6 +43,10 @@ export interface ConnectionCreateRequest {
|
|||||||
bootstrapPassword?: string
|
bootstrapPassword?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConnectionModalSubmitPayload extends ConnectionCreateRequest {
|
||||||
|
targetFolderId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionTreeNodePayload {
|
export interface SessionTreeNodePayload {
|
||||||
id: string
|
id: string
|
||||||
type: SessionTreeNodeType
|
type: SessionTreeNodeType
|
||||||
@@ -58,6 +64,12 @@ export interface SessionTreeLayoutPayload {
|
|||||||
sortMode?: SessionTreeSortMode
|
sortMode?: SessionTreeSortMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionTreeFolderOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SftpFileInfo {
|
export interface SftpFileInfo {
|
||||||
name: string
|
name: string
|
||||||
directory: boolean
|
directory: boolean
|
||||||
@@ -115,6 +127,21 @@ export interface BatchCommandResponse {
|
|||||||
results: BatchCommandResult[]
|
results: BatchCommandResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConnectionStatusItem {
|
||||||
|
connectionId: number
|
||||||
|
connectionName: string
|
||||||
|
status: 'online' | 'offline'
|
||||||
|
message: string
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionStatusResponse {
|
||||||
|
total: number
|
||||||
|
onlineCount: number
|
||||||
|
offlineCount: number
|
||||||
|
results: ConnectionStatusItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonitorMetrics {
|
export interface MonitorMetrics {
|
||||||
cpuUsage?: number | null
|
cpuUsage?: number | null
|
||||||
memTotal?: number | null
|
memTotal?: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user