fix(diagnosis): prevent progress stream blocking and refine tunnel type chips (#230)

* fix(diagnosis): avoid result channel deadlock in progress stream

Close the diagnosis result channel asynchronously after workers complete so progress can stream without blocking, and improve tunnel card type chip contrast for clearer protocol distinction.

* feat(diagnosis): stream pending items and render in-progress states

Pre-populate diagnosis stream with pending targets so tunnel and forward dialogs can show per-item diagnosing status immediately. Update result typing and UI states to distinguish in-progress, success, and failure rows/cards consistently.
This commit is contained in:
sagit
2026-03-01 14:49:38 +08:00
committed by GitHub
parent 9ffeb83753
commit cf51b305b0
6 changed files with 237 additions and 93 deletions
@@ -58,6 +58,55 @@ type diagnosisProgress struct {
type diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)
func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {
if len(workItems) == 0 {
return []map[string]interface{}{}
}
nodeCache := map[int64]*nodeRecord{}
items := make([]map[string]interface{}, 0, len(workItems))
for _, workItem := range workItems {
targetIP := strings.TrimSpace(workItem.targetIP)
targetPort := workItem.targetPort
if workItem.hasChainHop {
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
if err == nil {
resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference)
if resolveErr == nil {
targetIP = resolvedIP
targetPort = resolvedPort
}
}
}
if targetPort <= 0 {
targetPort = 443
}
nodeName := fmt.Sprintf("node_%d", workItem.fromNodeID)
if node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != "" {
nodeName = node.Name
}
item := map[string]interface{}{
"success": false,
"diagnosing": true,
"description": workItem.description,
"nodeName": nodeName,
"nodeId": strconv.FormatInt(workItem.fromNodeID, 10),
"targetIp": targetIP,
"targetPort": targetPort,
"message": "诊断中...",
}
for key, value := range workItem.metadata {
item[key] = value
}
items = append(items, item)
}
return items
}
const diagnosisMaxConcurrency = 8
const (
@@ -923,8 +972,10 @@ enqueueLoop:
}
}
close(jobs)
wg.Wait()
close(resultCh)
go func() {
wg.Wait()
close(resultCh)
}()
progress := diagnosisProgress{Total: len(workItems)}
for result := range resultCh {
@@ -81,6 +81,7 @@ func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.Can
payload := map[string]interface{}{
"total": len(workItems),
"timestamp": time.Now().UnixMilli(),
"items": h.buildDiagnosisStreamStartItems(workItems),
}
for key, value := range startPayload {
payload[key] = value
+87 -44
View File
@@ -890,10 +890,13 @@ export default function ForwardPage() {
? payload.forwardName
: forward.name;
const startTotal = Number(payload.total);
const startItems = Array.isArray(payload.items)
? (payload.items as ForwardDiagnosisResult["results"])
: [];
setDiagnosisResult((prev) => ({
forwardName: startForwardName,
timestamp: Date.now(),
results: prev?.results || [],
results: startItems.length > 0 ? startItems : prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
@@ -919,9 +922,15 @@ export default function ForwardPage() {
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
nextResults[existingIndex] = {
...result,
diagnosing: false,
};
} else {
nextResults.push(result);
nextResults.push({
...result,
diagnosing: false,
});
}
return {
...base,
@@ -3460,15 +3469,7 @@ export default function ForwardPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在诊断...</span>
</div>
</div>
) : diagnosisResult ? (
{diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
@@ -3599,6 +3600,10 @@ export default function ForwardPage() {
</thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => {
const isDiagnosing = Boolean(
result.diagnosing,
);
const isSuccess = result.success === true;
const quality =
getForwardDiagnosisQualityDisplay(
result.averageTime,
@@ -3609,22 +3614,28 @@ export default function ForwardPage() {
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground truncate">
{result.description}
@@ -3639,18 +3650,24 @@ export default function ForwardPage() {
<td className="px-3 py-2 text-center">
<Chip
color={
result.success
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span className="font-semibold text-primary">
{result.averageTime?.toFixed(0)}
</span>
@@ -3661,7 +3678,7 @@ export default function ForwardPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
@@ -3678,7 +3695,7 @@ export default function ForwardPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success && quality ? (
{isSuccess && quality ? (
<Chip
className="text-xs whitespace-nowrap"
color={quality.color as any}
@@ -3772,6 +3789,8 @@ export default function ForwardPage() {
</h3>
</div>
{results.map((result, index) => {
const isDiagnosing = Boolean(result.diagnosing);
const isSuccess = result.success === true;
const quality =
getForwardDiagnosisQualityDisplay(
result.averageTime,
@@ -3782,21 +3801,27 @@ export default function ForwardPage() {
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-foreground break-words">
{result.description}
@@ -3808,16 +3833,24 @@ export default function ForwardPage() {
<Chip
className="flex-shrink-0"
color={
result.success ? "success" : "danger"
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</div>
{result.success ? (
{isSuccess ? (
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
<div className="text-center">
<div className="text-lg font-bold text-primary">
@@ -3861,8 +3894,16 @@ export default function ForwardPage() {
</div>
) : (
<div className="mt-2 pt-2 border-t border-divider">
<div className="text-xs text-danger">
{result.message || "连接失败"}
<div
className={`text-xs ${
isDiagnosing
? "text-warning"
: "text-danger"
}`}
>
{isDiagnosing
? result.message || "诊断中..."
: result.message || "连接失败"}
</div>
</div>
)}
@@ -3903,14 +3944,16 @@ export default function ForwardPage() {
</div>
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
{diagnosisResult.results.some((r) => !r.success) && (
{diagnosisResult.results.some(
(r) => r.success === false && !r.diagnosing,
) && (
<div className="space-y-2 hidden md:block">
<h4 className="text-sm font-semibold text-danger">
失败详情
</h4>
<div className="space-y-2">
{diagnosisResult.results
.filter((r) => !r.success)
.filter((r) => r.success === false && !r.diagnosing)
.map((result, index) => (
<Alert
key={index}
+2 -1
View File
@@ -1,5 +1,6 @@
export interface ForwardDiagnosisEntry {
success: boolean;
success?: boolean;
diagnosing?: boolean;
description: string;
nodeName: string;
nodeId: string;
+92 -45
View File
@@ -490,11 +490,14 @@ export default function TunnelPage() {
? "端口转发"
: "隧道转发";
const startTotal = Number(payload.total);
const startItems = Array.isArray(payload.items)
? (payload.items as DiagnosisResult["results"])
: [];
setDiagnosisResult((prev) => ({
tunnelName: startTunnelName,
tunnelType: startTunnelType,
timestamp: Date.now(),
results: prev?.results || [],
results: startItems.length > 0 ? startItems : prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
@@ -521,9 +524,15 @@ export default function TunnelPage() {
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
nextResults[existingIndex] = {
...result,
diagnosing: false,
};
} else {
nextResults.push(result);
nextResults.push({
...result,
diagnosing: false,
});
}
return {
...base,
@@ -970,6 +979,10 @@ export default function TunnelPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedTunnels.map((tunnel) => {
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
const tunnelTypeChipClassName =
tunnel.type === 1
? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
return (
<SortableItem key={tunnel.id} id={tunnel.id}>
@@ -993,7 +1006,7 @@ export default function TunnelPage() {
</h3>
<div className="flex items-center gap-1.5 mt-1">
<Chip
className="text-xs"
className={tunnelTypeChipClassName}
color={typeDisplay.color as any}
size="sm"
variant="flat"
@@ -2106,15 +2119,7 @@ export default function TunnelPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在诊断...</span>
</div>
</div>
) : diagnosisResult ? (
{diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
@@ -2245,6 +2250,10 @@ export default function TunnelPage() {
</thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => {
const isDiagnosing = Boolean(
result.diagnosing,
);
const isSuccess = result.success === true;
const quality = getDiagnosisQualityDisplay(
result.averageTime,
result.packetLoss,
@@ -2254,22 +2263,28 @@ export default function TunnelPage() {
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground truncate">
{result.description}
@@ -2284,18 +2299,24 @@ export default function TunnelPage() {
<td className="px-3 py-2 text-center">
<Chip
color={
result.success
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span className="font-semibold text-primary">
{result.averageTime?.toFixed(0)}
</span>
@@ -2306,7 +2327,7 @@ export default function TunnelPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
@@ -2323,7 +2344,7 @@ export default function TunnelPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success && quality ? (
{isSuccess && quality ? (
<Chip
className="text-xs whitespace-nowrap"
color={quality.color as any}
@@ -2417,6 +2438,8 @@ export default function TunnelPage() {
</h3>
</div>
{results.map((result, index) => {
const isDiagnosing = Boolean(result.diagnosing);
const isSuccess = result.success === true;
const quality = getDiagnosisQualityDisplay(
result.averageTime,
result.packetLoss,
@@ -2426,21 +2449,27 @@ export default function TunnelPage() {
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-foreground break-words">
{result.description}
@@ -2452,16 +2481,24 @@ export default function TunnelPage() {
<Chip
className="flex-shrink-0"
color={
result.success ? "success" : "danger"
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</div>
{result.success ? (
{isSuccess ? (
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
<div className="text-center">
<div className="text-lg font-bold text-primary">
@@ -2505,8 +2542,16 @@ export default function TunnelPage() {
</div>
) : (
<div className="mt-2 pt-2 border-t border-divider">
<div className="text-xs text-danger">
{result.message || "连接失败"}
<div
className={`text-xs ${
isDiagnosing
? "text-warning"
: "text-danger"
}`}
>
{isDiagnosing
? result.message || "诊断中..."
: result.message || "连接失败"}
</div>
</div>
)}
@@ -2547,14 +2592,16 @@ export default function TunnelPage() {
</div>
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
{diagnosisResult.results.some((r) => !r.success) && (
{diagnosisResult.results.some(
(r) => r.success === false && !r.diagnosing,
) && (
<div className="space-y-2 hidden md:block">
<h4 className="text-sm font-semibold text-danger">
失败详情
</h4>
<div className="space-y-2">
{diagnosisResult.results
.filter((r) => !r.success)
.filter((r) => r.success === false && !r.diagnosing)
.map((result, index) => (
<Alert
key={index}
+2 -1
View File
@@ -1,5 +1,6 @@
export interface DiagnosisEntry {
success: boolean;
success?: boolean;
diagnosing?: boolean;
description: string;
nodeName: string;
nodeId: string;