Merge PR #1300: tailscale Windows fix, quota pagination, SSE abort handling
- fix(tunnel): cross-platform tailscale probes without shell redirection - feat(usage): paginate provider limits with page size controls - feat(providers): stop control for one-by-one connection testing - fix(sse): close stream gracefully on abort/disconnect instead of pipe errors - ui(quota): simplify header, always show pagination in one row Co-authored-by: philau2512 <dplau25122002@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
e84ab7857a
commit
6b0dc09239
8 files changed
+1183
-295
No files matched your search
@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
|
||||
import { Badge, Toggle } from "@/shared/components";
|
||||
import CooldownTimer from "./CooldownTimer";
|
||||
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) {
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null }) {
|
||||
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||
const proxyDropdownRef = useRef(null);
|
||||
@@ -114,6 +114,23 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
return "default";
|
||||
};
|
||||
|
||||
const getOneByOneVariant = () => {
|
||||
if (!oneByOneStatus) return "default";
|
||||
if (oneByOneStatus.state === "success") return "success";
|
||||
if (oneByOneStatus.state === "failed") return "error";
|
||||
if (oneByOneStatus.state === "testing") return "primary";
|
||||
return "default";
|
||||
};
|
||||
|
||||
const getOneByOneLabel = () => {
|
||||
if (!oneByOneStatus) return null;
|
||||
if (oneByOneStatus.state === "queued") return "queued";
|
||||
if (oneByOneStatus.state === "testing") return "testing";
|
||||
if (oneByOneStatus.state === "success") return "success";
|
||||
if (oneByOneStatus.state === "failed") return oneByOneStatus.error ? `failed: ${oneByOneStatus.error}` : "failed";
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`group flex min-w-0 flex-col gap-3 rounded-lg p-2 transition-colors hover:bg-black/[0.02] dark:hover:bg-white/[0.02] sm:flex-row sm:items-center sm:justify-between ${connection.isActive === false ? "opacity-60" : ""}`}>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2 sm:items-center sm:gap-3">
|
||||
@@ -161,6 +178,11 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
{connection.globalPriority && (
|
||||
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
||||
)}
|
||||
{getOneByOneLabel() && (
|
||||
<Badge variant={getOneByOneVariant()} size="sm">
|
||||
{getOneByOneLabel()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasAnyProxy && (
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
@@ -266,4 +288,8 @@ ConnectionRow.propTypes = {
|
||||
onUpdateProxy: PropTypes.func,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
oneByOneStatus: PropTypes.shape({
|
||||
state: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
@@ -17,6 +17,12 @@ import AddApiKeyModal from "./AddApiKeyModal";
|
||||
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
||||
import AddCustomModelModal from "./AddCustomModelModal";
|
||||
|
||||
const ONE_BY_ONE_DELAY_MS = 1000;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export default function ProviderDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -50,6 +56,12 @@ export default function ProviderDetailPage() {
|
||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const [showAgRiskModal, setShowAgRiskModal] = useState(false);
|
||||
const [oneByOneRunning, setOneByOneRunning] = useState(false);
|
||||
const [oneByOneStopping, setOneByOneStopping] = useState(false);
|
||||
const [oneByOneCurrentConnectionId, setOneByOneCurrentConnectionId] = useState(null);
|
||||
const [oneByOneResults, setOneByOneResults] = useState({});
|
||||
const [oneByOneSummary, setOneByOneSummary] = useState(null);
|
||||
const stopOneByOneRef = useRef(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
|
||||
@@ -397,6 +409,98 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunOneByOneTest = async () => {
|
||||
if (oneByOneRunning || connections.length === 0) return;
|
||||
|
||||
const queuedState = Object.fromEntries(
|
||||
connections.map((connection) => [connection.id, { state: "queued", error: null }]),
|
||||
);
|
||||
|
||||
stopOneByOneRef.current = false;
|
||||
setOneByOneRunning(true);
|
||||
setOneByOneStopping(false);
|
||||
setOneByOneCurrentConnectionId(null);
|
||||
setOneByOneResults(queuedState);
|
||||
setOneByOneSummary({ total: connections.length, completed: 0, passed: 0, failed: 0, stopped: false });
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
try {
|
||||
for (let index = 0; index < connections.length; index += 1) {
|
||||
if (stopOneByOneRef.current) {
|
||||
setOneByOneSummary({
|
||||
total: connections.length,
|
||||
completed: index,
|
||||
passed,
|
||||
failed,
|
||||
stopped: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const connection = connections[index];
|
||||
setOneByOneCurrentConnectionId(connection.id);
|
||||
setOneByOneResults((prev) => ({
|
||||
...prev,
|
||||
[connection.id]: { state: "testing", error: null },
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
const valid = !!data.valid;
|
||||
|
||||
if (valid) {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
|
||||
setOneByOneResults((prev) => ({
|
||||
...prev,
|
||||
[connection.id]: {
|
||||
state: valid ? "success" : "failed",
|
||||
error: valid ? null : (data.error || null),
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
setOneByOneResults((prev) => ({
|
||||
...prev,
|
||||
[connection.id]: {
|
||||
state: "failed",
|
||||
error: error.message || "Test failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setOneByOneSummary({
|
||||
total: connections.length,
|
||||
completed: index + 1,
|
||||
passed,
|
||||
failed,
|
||||
stopped: false,
|
||||
});
|
||||
|
||||
if (index < connections.length - 1) {
|
||||
await sleep(ONE_BY_ONE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setOneByOneCurrentConnectionId(null);
|
||||
setOneByOneRunning(false);
|
||||
setOneByOneStopping(false);
|
||||
stopOneByOneRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopOneByOneTest = () => {
|
||||
if (!oneByOneRunning) return;
|
||||
stopOneByOneRef.current = true;
|
||||
setOneByOneStopping(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setConfirmState({
|
||||
title: "Delete Connection",
|
||||
@@ -646,6 +750,7 @@ export default function ProviderDetailPage() {
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
oneByOneStatus={oneByOneResults[conn.id] || null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1063,6 +1168,30 @@ export default function ProviderDetailPage() {
|
||||
Apply Proxy
|
||||
</Button>
|
||||
)}
|
||||
{connections.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="sync"
|
||||
onClick={handleRunOneByOneTest}
|
||||
disabled={oneByOneRunning}
|
||||
>
|
||||
{oneByOneRunning ? "Testing Connection One-by-One..." : "Test Connection One-by-One"}
|
||||
</Button>
|
||||
{oneByOneRunning && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="stop"
|
||||
onClick={handleStopOneByOneTest}
|
||||
disabled={oneByOneStopping}
|
||||
>
|
||||
{oneByOneStopping ? "Stopping..." : "Stop"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Thinking config */}
|
||||
{/* {thinkingConfig && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1147,6 +1276,22 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{oneByOneSummary && (
|
||||
<div className="mb-4 rounded-lg border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span>Total: {oneByOneSummary.total}</span>
|
||||
<span>Completed: {oneByOneSummary.completed}</span>
|
||||
<span>Passed: {oneByOneSummary.passed}</span>
|
||||
<span>Failed: {oneByOneSummary.failed}</span>
|
||||
{oneByOneSummary.stopped && (
|
||||
<span className="text-amber-600 dark:text-amber-400">Stopped</span>
|
||||
)}
|
||||
{oneByOneRunning && oneByOneCurrentConnectionId && (
|
||||
<span>Running: {connections.find((conn) => conn.id === oneByOneCurrentConnectionId)?.name || oneByOneCurrentConnectionId}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{connectionsList}
|
||||
{!isCompatible && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
|
||||
|
||||
Reference in new issue
Block a user