fix(snapshot): 前端 API 改走 Next.js rewrites 代理 + WebSocket 改 REST 轮询
- snapshot.api.ts: 从直连 localhost:port 改为 /api/snapshots/* 走 Next.js 代理 - next.config: 两个前端都添加 /api/snapshots/:path* → snapshot-service 代理规则 - docker-compose.2.0-snapshot.yml: overlay 中追加 mining-admin-web 的 SNAPSHOT_SERVICE_URL - useSnapshotWebSocket → useSnapshotPolling: 2秒轮询 GET /snapshots/:id 获取进度 - 移除 socket.io-client 依赖(Next.js standalone 不支持 WebSocket proxy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9a88fb473a
commit
ee94f1420d
|
|
@ -4,6 +4,11 @@
|
|||
# 纯新增 overlay,不修改任何现有服务配置
|
||||
|
||||
services:
|
||||
# 为 mining-admin-web 补充 snapshot-service 代理地址
|
||||
mining-admin-web:
|
||||
environment:
|
||||
- SNAPSHOT_SERVICE_URL=http://snapshot-service-2:3199
|
||||
|
||||
snapshot-service-2:
|
||||
build:
|
||||
context: ./snapshot-service
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const snapshotServiceUrl = process.env.SNAPSHOT_SERVICE_URL || 'http://localhost:3099';
|
||||
return [
|
||||
{
|
||||
source: '/api/snapshots/:path*',
|
||||
destination: `${snapshotServiceUrl}/api/v1/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@
|
|||
"react-redux": "^9.2.0",
|
||||
"recharts": "^2.15.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { snapshotApi } from '@/infrastructure/api/snapshot.api';
|
||||
import { useSnapshotWebSocket } from '@/hooks/useSnapshotWebSocket';
|
||||
import { useSnapshotPolling } from '@/hooks/useSnapshotPolling';
|
||||
import type {
|
||||
BackupTarget,
|
||||
StorageType,
|
||||
|
|
@ -66,8 +66,8 @@ export default function SnapshotsPage() {
|
|||
|
||||
// 当前执行中的任务
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const { progresses, isConnected, taskCompleted, totalSize, duration } =
|
||||
useSnapshotWebSocket(activeTaskId);
|
||||
const { progresses, taskCompleted, totalSize, duration } =
|
||||
useSnapshotPolling(activeTaskId);
|
||||
|
||||
// 历史列表
|
||||
const [tasks, setTasks] = useState<SnapshotTask[]>([]);
|
||||
|
|
@ -245,10 +245,7 @@ export default function SnapshotsPage() {
|
|||
{/* 实时进度 */}
|
||||
{activeTaskId && progresses.size > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>
|
||||
备份进度
|
||||
{isConnected && <span className={styles.wsStatus}>WebSocket 已连接</span>}
|
||||
</h2>
|
||||
<h2 className={styles.cardTitle}>备份进度</h2>
|
||||
|
||||
<div className={styles.progressList}>
|
||||
{Array.from(progresses.values()).map((p) => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { snapshotApi } from '@/infrastructure/api/snapshot.api';
|
||||
import type { BackupTarget, SnapshotTask } from '@/types/snapshot.types';
|
||||
|
||||
interface TargetProgress {
|
||||
target: BackupTarget;
|
||||
percent: number;
|
||||
message: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface UseSnapshotPollingReturn {
|
||||
progresses: Map<string, TargetProgress>;
|
||||
taskCompleted: boolean;
|
||||
totalSize: string | null;
|
||||
duration: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2000; // 2 秒轮询
|
||||
|
||||
export function useSnapshotPolling(taskId: string | null): UseSnapshotPollingReturn {
|
||||
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
|
||||
const [taskCompleted, setTaskCompleted] = useState(false);
|
||||
const [totalSize, setTotalSize] = useState<string | null>(null);
|
||||
const [duration, setDuration] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const updateFromTask = useCallback((task: SnapshotTask) => {
|
||||
// 从 task.details 构建进度 Map
|
||||
const next = new Map<string, TargetProgress>();
|
||||
for (const d of task.details) {
|
||||
const statusMap: Record<string, TargetProgress['status']> = {
|
||||
PENDING: 'pending',
|
||||
RUNNING: 'running',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
next.set(d.target, {
|
||||
target: d.target,
|
||||
percent: d.status === 'COMPLETED' ? 100 : d.progress,
|
||||
message:
|
||||
d.error
|
||||
? d.error
|
||||
: d.status === 'COMPLETED'
|
||||
? '完成'
|
||||
: d.status === 'RUNNING'
|
||||
? `备份中... ${d.progress}%`
|
||||
: '等待中',
|
||||
status: statusMap[d.status] || 'pending',
|
||||
});
|
||||
}
|
||||
setProgresses(next);
|
||||
|
||||
// 任务整体完成/失败 → 停止轮询
|
||||
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
|
||||
setTaskCompleted(true);
|
||||
setTotalSize(task.totalSize);
|
||||
if (task.startedAt && task.completedAt) {
|
||||
setDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime());
|
||||
}
|
||||
if (task.error) {
|
||||
setError(task.error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
|
||||
// 重置状态
|
||||
setProgresses(new Map());
|
||||
setTaskCompleted(false);
|
||||
setTotalSize(null);
|
||||
setDuration(null);
|
||||
setError(null);
|
||||
|
||||
let stopped = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const task = await snapshotApi.getById(taskId);
|
||||
if (stopped) return;
|
||||
updateFromTask(task);
|
||||
|
||||
// 任务结束,停止轮询
|
||||
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 网络错误静默忽略,下次继续轮询
|
||||
}
|
||||
};
|
||||
|
||||
// 立即拉一次
|
||||
poll();
|
||||
// 定时轮询
|
||||
timerRef.current = setInterval(poll, POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId, updateFromTask]);
|
||||
|
||||
return { progresses, taskCompleted, totalSize, duration, error };
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import type { BackupTarget, SnapshotProgress } from '@/types/snapshot.types';
|
||||
|
||||
const SNAPSHOT_WS_URL = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3099';
|
||||
|
||||
interface TargetProgress {
|
||||
target: BackupTarget;
|
||||
percent: number;
|
||||
message: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface UseSnapshotWebSocketReturn {
|
||||
progresses: Map<string, TargetProgress>;
|
||||
isConnected: boolean;
|
||||
taskCompleted: boolean;
|
||||
totalSize: string | null;
|
||||
duration: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useSnapshotWebSocket(taskId: string | null): UseSnapshotWebSocketReturn {
|
||||
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [taskCompleted, setTaskCompleted] = useState(false);
|
||||
const [totalSize, setTotalSize] = useState<string | null>(null);
|
||||
const [duration, setDuration] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
|
||||
// 重置状态
|
||||
setProgresses(new Map());
|
||||
setTaskCompleted(false);
|
||||
setTotalSize(null);
|
||||
setDuration(null);
|
||||
setError(null);
|
||||
|
||||
const socket = io(`${SNAPSHOT_WS_URL}/snapshots`, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => setIsConnected(true));
|
||||
socket.on('disconnect', () => setIsConnected(false));
|
||||
|
||||
socket.on('snapshot:started', (data: { taskId: string; targets: BackupTarget[] }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
const initial = new Map<string, TargetProgress>();
|
||||
data.targets.forEach((t) => {
|
||||
initial.set(t, { target: t, percent: 0, message: '等待中', status: 'pending' });
|
||||
});
|
||||
setProgresses(initial);
|
||||
});
|
||||
|
||||
socket.on('snapshot:progress', (data: SnapshotProgress) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
setProgresses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(data.target, {
|
||||
target: data.target,
|
||||
percent: data.percent,
|
||||
message: data.message,
|
||||
status: 'running',
|
||||
});
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('snapshot:target-complete', (data: { taskId: string; target: string; fileSize: string }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
setProgresses((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(data.target);
|
||||
if (existing) {
|
||||
next.set(data.target, { ...existing, percent: 100, message: '完成', status: 'completed' });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('snapshot:complete', (data: { taskId: string; totalSize: string; duration: number }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
setTaskCompleted(true);
|
||||
setTotalSize(data.totalSize);
|
||||
setDuration(data.duration);
|
||||
});
|
||||
|
||||
socket.on('snapshot:error', (data: { taskId: string; target?: string; error: string }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
if (data.target) {
|
||||
setProgresses((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(data.target!);
|
||||
if (existing) {
|
||||
next.set(data.target!, { ...existing, message: data.error, status: 'failed' });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setError(data.error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
return { progresses, isConnected, taskCompleted, totalSize, duration, error };
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import type { CreateSnapshotDto, SnapshotTask } from '@/types/snapshot.types';
|
||||
|
||||
const SNAPSHOT_BASE = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3099';
|
||||
|
||||
/** snapshot-service 独立请求(不走通用 apiClient,因为 snapshot 服务有独立端口) */
|
||||
/** snapshot-service 请求 — 走 Next.js rewrites 代理 */
|
||||
async function snapshotFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
const res = await fetch(`${SNAPSHOT_BASE}/api/v1${url}`, {
|
||||
const res = await fetch(`/api/snapshots${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
|
|
@ -40,5 +38,5 @@ export const snapshotApi = {
|
|||
snapshotFetch<{ message: string }>(`/snapshots/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getDownloadUrl: (id: string, target: string) =>
|
||||
`${SNAPSHOT_BASE}/api/v1/snapshots/${id}/download/${target}`,
|
||||
`/api/snapshots/snapshots/${id}/download/${target}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const nextConfig = {
|
|||
const miningAdminUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023';
|
||||
const tradingServiceUrl = process.env.TRADING_SERVICE_URL || 'http://localhost:3022';
|
||||
const miningServiceUrl = process.env.MINING_SERVICE_URL || 'http://localhost:3021';
|
||||
const snapshotServiceUrl = process.env.SNAPSHOT_SERVICE_URL || 'http://localhost:3199';
|
||||
|
||||
// 移除末尾可能存在的路径避免重复
|
||||
const cleanMiningAdminUrl = miningAdminUrl.replace(/\/api\/v2.*$/, '');
|
||||
|
|
@ -19,6 +20,10 @@ const nextConfig = {
|
|||
if (apiGatewayUrl) {
|
||||
// 通过 Kong 网关: 所有请求经 Kong 路由分发到各服务
|
||||
return [
|
||||
{
|
||||
source: '/api/snapshots/:path*',
|
||||
destination: `${snapshotServiceUrl}/api/v1/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/api/trading/:path*',
|
||||
destination: `${apiGatewayUrl}/api/v2/trading/:path*`,
|
||||
|
|
@ -35,6 +40,10 @@ const nextConfig = {
|
|||
} else {
|
||||
// 直连各服务: 前端与后端在同一 Docker 网络内直接通信
|
||||
return [
|
||||
{
|
||||
source: '/api/snapshots/:path*',
|
||||
destination: `${snapshotServiceUrl}/api/v1/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/api/trading/:path*',
|
||||
destination: `${cleanTradingUrl}/api/v2/:path*`,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.50.1",
|
||||
"react-redux": "^9.1.0",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { snapshotApi } from '@/lib/api/snapshot.api';
|
||||
import { useSnapshotWebSocket } from '@/lib/hooks/useSnapshotWebSocket';
|
||||
import { useSnapshotPolling } from '@/lib/hooks/useSnapshotPolling';
|
||||
import type {
|
||||
BackupTarget,
|
||||
StorageType,
|
||||
|
|
@ -47,8 +47,8 @@ export default function SnapshotsPage() {
|
|||
const [selectedTargets, setSelectedTargets] = useState<BackupTarget[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const { progresses, isConnected, taskCompleted, totalSize, duration } =
|
||||
useSnapshotWebSocket(activeTaskId);
|
||||
const { progresses, taskCompleted, totalSize, duration } =
|
||||
useSnapshotPolling(activeTaskId);
|
||||
const [tasks, setTasks] = useState<SnapshotTask[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
|
@ -222,14 +222,7 @@ export default function SnapshotsPage() {
|
|||
{/* 实时进度 */}
|
||||
{activeTaskId && progresses.size > 0 && (
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
||||
<h2 className="mb-4 flex items-center gap-3 text-lg font-semibold">
|
||||
备份进度
|
||||
{isConnected && (
|
||||
<span className="rounded bg-green-50 px-2 py-0.5 text-xs font-normal text-green-600">
|
||||
WebSocket 已连接
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold">备份进度</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Array.from(progresses.values()).map((p) => (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import type { CreateSnapshotDto, SnapshotTask } from '@/types/snapshot.types';
|
||||
|
||||
const SNAPSHOT_BASE = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3199';
|
||||
|
||||
async function snapshotFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null;
|
||||
const res = await fetch(`${SNAPSHOT_BASE}/api/v1${url}`, {
|
||||
const res = await fetch(`/api/snapshots${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
|
|
@ -39,5 +37,5 @@ export const snapshotApi = {
|
|||
snapshotFetch<{ message: string }>(`/snapshots/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getDownloadUrl: (id: string, target: string) =>
|
||||
`${SNAPSHOT_BASE}/api/v1/snapshots/${id}/download/${target}`,
|
||||
`/api/snapshots/snapshots/${id}/download/${target}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { snapshotApi } from '@/lib/api/snapshot.api';
|
||||
import type { BackupTarget, SnapshotTask } from '@/types/snapshot.types';
|
||||
|
||||
interface TargetProgress {
|
||||
target: BackupTarget;
|
||||
percent: number;
|
||||
message: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface UseSnapshotPollingReturn {
|
||||
progresses: Map<string, TargetProgress>;
|
||||
taskCompleted: boolean;
|
||||
totalSize: string | null;
|
||||
duration: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2000; // 2 秒轮询
|
||||
|
||||
export function useSnapshotPolling(taskId: string | null): UseSnapshotPollingReturn {
|
||||
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
|
||||
const [taskCompleted, setTaskCompleted] = useState(false);
|
||||
const [totalSize, setTotalSize] = useState<string | null>(null);
|
||||
const [duration, setDuration] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const updateFromTask = useCallback((task: SnapshotTask) => {
|
||||
// 从 task.details 构建进度 Map
|
||||
const next = new Map<string, TargetProgress>();
|
||||
for (const d of task.details) {
|
||||
const statusMap: Record<string, TargetProgress['status']> = {
|
||||
PENDING: 'pending',
|
||||
RUNNING: 'running',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
next.set(d.target, {
|
||||
target: d.target,
|
||||
percent: d.status === 'COMPLETED' ? 100 : d.progress,
|
||||
message:
|
||||
d.error
|
||||
? d.error
|
||||
: d.status === 'COMPLETED'
|
||||
? '完成'
|
||||
: d.status === 'RUNNING'
|
||||
? `备份中... ${d.progress}%`
|
||||
: '等待中',
|
||||
status: statusMap[d.status] || 'pending',
|
||||
});
|
||||
}
|
||||
setProgresses(next);
|
||||
|
||||
// 任务整体完成/失败 → 停止轮询
|
||||
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
|
||||
setTaskCompleted(true);
|
||||
setTotalSize(task.totalSize);
|
||||
if (task.startedAt && task.completedAt) {
|
||||
setDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime());
|
||||
}
|
||||
if (task.error) {
|
||||
setError(task.error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
|
||||
// 重置状态
|
||||
setProgresses(new Map());
|
||||
setTaskCompleted(false);
|
||||
setTotalSize(null);
|
||||
setDuration(null);
|
||||
setError(null);
|
||||
|
||||
let stopped = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const task = await snapshotApi.getById(taskId);
|
||||
if (stopped) return;
|
||||
updateFromTask(task);
|
||||
|
||||
// 任务结束,停止轮询
|
||||
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 网络错误静默忽略,下次继续轮询
|
||||
}
|
||||
};
|
||||
|
||||
// 立即拉一次
|
||||
poll();
|
||||
// 定时轮询
|
||||
timerRef.current = setInterval(poll, POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId, updateFromTask]);
|
||||
|
||||
return { progresses, taskCompleted, totalSize, duration, error };
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import type { BackupTarget, SnapshotProgress } from '@/types/snapshot.types';
|
||||
|
||||
const SNAPSHOT_WS_URL = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3199';
|
||||
|
||||
interface TargetProgress {
|
||||
target: BackupTarget;
|
||||
percent: number;
|
||||
message: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface UseSnapshotWebSocketReturn {
|
||||
progresses: Map<string, TargetProgress>;
|
||||
isConnected: boolean;
|
||||
taskCompleted: boolean;
|
||||
totalSize: string | null;
|
||||
duration: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useSnapshotWebSocket(taskId: string | null): UseSnapshotWebSocketReturn {
|
||||
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [taskCompleted, setTaskCompleted] = useState(false);
|
||||
const [totalSize, setTotalSize] = useState<string | null>(null);
|
||||
const [duration, setDuration] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
|
||||
setProgresses(new Map());
|
||||
setTaskCompleted(false);
|
||||
setTotalSize(null);
|
||||
setDuration(null);
|
||||
setError(null);
|
||||
|
||||
const socket = io(`${SNAPSHOT_WS_URL}/snapshots`, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => setIsConnected(true));
|
||||
socket.on('disconnect', () => setIsConnected(false));
|
||||
|
||||
socket.on('snapshot:started', (data: { taskId: string; targets: BackupTarget[] }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
const initial = new Map<string, TargetProgress>();
|
||||
data.targets.forEach((t) => {
|
||||
initial.set(t, { target: t, percent: 0, message: '等待中', status: 'pending' });
|
||||
});
|
||||
setProgresses(initial);
|
||||
});
|
||||
|
||||
socket.on('snapshot:progress', (data: SnapshotProgress) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
setProgresses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(data.target, {
|
||||
target: data.target,
|
||||
percent: data.percent,
|
||||
message: data.message,
|
||||
status: 'running',
|
||||
});
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('snapshot:target-complete', (data: { taskId: string; target: string; fileSize: string }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
setProgresses((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(data.target);
|
||||
if (existing) {
|
||||
next.set(data.target, { ...existing, percent: 100, message: '完成', status: 'completed' });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('snapshot:complete', (data: { taskId: string; totalSize: string; duration: number }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
setTaskCompleted(true);
|
||||
setTotalSize(data.totalSize);
|
||||
setDuration(data.duration);
|
||||
});
|
||||
|
||||
socket.on('snapshot:error', (data: { taskId: string; target?: string; error: string }) => {
|
||||
if (data.taskId !== taskId) return;
|
||||
if (data.target) {
|
||||
setProgresses((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(data.target!);
|
||||
if (existing) {
|
||||
next.set(data.target!, { ...existing, message: data.error, status: 'failed' });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setError(data.error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
return { progresses, isConnected, taskCompleted, totalSize, duration, error };
|
||||
}
|
||||
Loading…
Reference in New Issue