diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index bc91b2e..f3220f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,23 @@ -# Backend -backend/target/ -backend/data/*.db -backend/data/*.mv.db - -# Frontend -frontend/node_modules/ -frontend/dist/ - +# Backend +backend/target/ +backend/data/*.db +backend/data/*.mv.db + +# Frontend +frontend/node_modules/ +frontend/dist/ + # Logs & IDE *.log .idea .DS_Store *.local -.codex /package-lock.json release/ .opencode/ # Worktrees .worktrees/ - -# Keep frontend .gitignore for frontend-specific rules -!frontend/.gitignore + +# Keep frontend .gitignore for frontend-specific rules +!frontend/.gitignore diff --git a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java index e59763e..5b32339 100644 --- a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java +++ b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java @@ -6,12 +6,15 @@ import com.sshmanager.dto.BackupImportResponseDto; import com.sshmanager.dto.BackupPackageDto; import com.sshmanager.dto.BatchCommandRequest; import com.sshmanager.dto.BatchCommandResponseDto; +import com.sshmanager.dto.ConnectionStatusCheckRequest; +import com.sshmanager.dto.ConnectionStatusResponseDto; import com.sshmanager.entity.Connection; import com.sshmanager.entity.User; import com.sshmanager.repository.UserRepository; import com.sshmanager.service.BackupService; import com.sshmanager.service.BatchCommandService; import com.sshmanager.service.ConnectionService; +import com.sshmanager.service.ConnectionStatusService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -27,15 +30,18 @@ public class ConnectionController { private final ConnectionService connectionService; private final BackupService backupService; private final BatchCommandService batchCommandService; + private final ConnectionStatusService connectionStatusService; private final UserRepository userRepository; public ConnectionController(ConnectionService connectionService, BackupService backupService, BatchCommandService batchCommandService, + ConnectionStatusService connectionStatusService, UserRepository userRepository) { this.connectionService = connectionService; this.backupService = backupService; this.batchCommandService = batchCommandService; + this.connectionStatusService = connectionStatusService; this.userRepository = userRepository; } @@ -101,9 +107,16 @@ public class ConnectionController { return ResponseEntity.ok(batchCommandService.execute(userId, request)); } + @PostMapping("/status") + public ResponseEntity checkStatuses(@RequestBody ConnectionStatusCheckRequest request, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(connectionStatusService.checkStatuses(userId, request)); + } + @PostMapping("/test") - public ResponseEntity> connectivity(@RequestBody Connection connection, - Authentication authentication) { + public ResponseEntity> connectivity(@RequestBody Connection connection, + Authentication authentication) { try { Long userId = getCurrentUserId(authentication); Connection fullConn = connectionService.getConnectionForSsh(connection.getId(), userId); diff --git a/backend/src/main/java/com/sshmanager/dto/ConnectionStatusCheckRequest.java b/backend/src/main/java/com/sshmanager/dto/ConnectionStatusCheckRequest.java new file mode 100644 index 0000000..aa979ba --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/ConnectionStatusCheckRequest.java @@ -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 connectionIds = new ArrayList(); +} diff --git a/backend/src/main/java/com/sshmanager/dto/ConnectionStatusItemDto.java b/backend/src/main/java/com/sshmanager/dto/ConnectionStatusItemDto.java new file mode 100644 index 0000000..826b55a --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/ConnectionStatusItemDto.java @@ -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; +} diff --git a/backend/src/main/java/com/sshmanager/dto/ConnectionStatusResponseDto.java b/backend/src/main/java/com/sshmanager/dto/ConnectionStatusResponseDto.java new file mode 100644 index 0000000..fea234b --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/ConnectionStatusResponseDto.java @@ -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 results = new ArrayList(); +} diff --git a/backend/src/main/java/com/sshmanager/service/ConnectionStatusService.java b/backend/src/main/java/com/sshmanager/service/ConnectionStatusService.java new file mode 100644 index 0000000..e16a0c1 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/service/ConnectionStatusService.java @@ -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 results = new ArrayList(); + 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; + } +} diff --git a/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java b/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java index fee8bbb..8982c37 100644 --- a/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java +++ b/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java @@ -6,10 +6,13 @@ import com.sshmanager.dto.BackupImportResponseDto; import com.sshmanager.dto.BackupPackageDto; import com.sshmanager.dto.BatchCommandRequest; import com.sshmanager.dto.BatchCommandResponseDto; +import com.sshmanager.dto.ConnectionStatusCheckRequest; +import com.sshmanager.dto.ConnectionStatusResponseDto; import com.sshmanager.repository.UserRepository; import com.sshmanager.service.BackupService; import com.sshmanager.service.BatchCommandService; import com.sshmanager.service.ConnectionService; +import com.sshmanager.service.ConnectionStatusService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -47,6 +50,9 @@ class ConnectionControllerTest { @Mock private BatchCommandService batchCommandService; + @Mock + private ConnectionStatusService connectionStatusService; + @Mock private UserRepository userRepository; @@ -149,4 +155,18 @@ class ConnectionControllerTest { assertEquals(expected, response.getBody()); 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 response = connectionController.checkStatuses(request, authentication); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(expected, response.getBody()); + verify(connectionStatusService).checkStatuses(1L, request); + } } diff --git a/backend/src/test/java/com/sshmanager/service/ConnectionStatusServiceTest.java b/backend/src/test/java/com/sshmanager/service/ConnectionStatusServiceTest.java new file mode 100644 index 0000000..d705f09 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/service/ConnectionStatusServiceTest.java @@ -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()); + } +} diff --git a/frontend/public/ssh-manager.svg b/frontend/public/ssh-manager.svg index d9d7d1a..b2338d0 100644 --- a/frontend/public/ssh-manager.svg +++ b/frontend/public/ssh-manager.svg @@ -1,103 +1,38 @@ - + + - - - + + + - - - - - - - - - - - - - - - + + + - - - - - - - - + + + + - - + + - - - - - - - - - - - - - - + + + - - - - - - - $ ssh -i keys/mgmt.pem - admin@prod-1.net - $ - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/frontend/src/components/BatchCommandModal.tsx b/frontend/src/components/BatchCommandModal.tsx index 4b98cba..bbc735e 100644 --- a/frontend/src/components/BatchCommandModal.tsx +++ b/frontend/src/components/BatchCommandModal.tsx @@ -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 Modal from './Modal' import { executeBatchCommand } from '../services/connections' -import type { BatchCommandResult, Connection } from '../types' +import type { BatchCommandResult, Connection, ConnectionReachabilityStatus, ConnectionStatusItem } from '../types' + +const reachabilityCopy: Record = { + 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({ open, connections, + connectionStatuses, + connectionStatusDetails, + onRefreshStatuses, + statusError, + statusLoading, onClose, }: { open: boolean connections: Connection[] + connectionStatuses: Record + connectionStatusDetails: Record + onRefreshStatuses: () => Promise + statusError: string | null + statusLoading: boolean onClose: () => void }) { const [selectedIds, setSelectedIds] = useState(() => connections.slice(0, 2).map((item) => item.id)) @@ -28,14 +45,35 @@ export default function BatchCommandModal({ [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 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) setError(null) try { - const response = await executeBatchCommand(selectedIds, command) + const response = await executeBatchCommand(runnableIds, command) setResults(response.data.results) } catch (err) { const message = @@ -53,21 +91,45 @@ export default function BatchCommandModal({
目标主机 ({selectedIds.length})
- +
+
+ 在线 {onlineIds.length} / 离线 {offlineCount} / 总数 {connections.length} +
{connections.map((connection) => { 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 ( -