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:
liumangmang
2026-05-07 10:09:40 +08:00
parent 165cc0e35b
commit f24d0f69ed
19 changed files with 1757 additions and 367 deletions
View File
+12 -13
View File
@@ -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());
}
}
+28 -93
View File
@@ -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

+79 -10
View File
@@ -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) => (
+74 -22
View File
@@ -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 }))}
/> />
+105
View File
@@ -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>
)
}
+93 -11
View File
@@ -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>
) )
} }
+60 -87
View File
@@ -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
View File
@@ -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' }
} }
+732 -70
View File
@@ -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>
) )
} }
+5 -1
View File
@@ -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 })
}
+27
View File
@@ -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