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:
parent
e84ab7857a
commit
6b0dc09239
8 changed files with 1183 additions and 295 deletions
|
|
@ -113,10 +113,22 @@ export function createDisconnectAwareStream(transformStream, streamController) {
|
||||||
}
|
}
|
||||||
controller.enqueue(value);
|
controller.enqueue(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const wasConnected = streamController.isConnected();
|
||||||
streamController.handleError(error);
|
streamController.handleError(error);
|
||||||
reader.cancel().catch(() => {});
|
reader.cancel().catch(() => {});
|
||||||
writer.abort().catch(() => {});
|
writer.abort().catch(() => {});
|
||||||
controller.error(error);
|
|
||||||
|
if (!wasConnected || error.name === "AbortError" || error.message?.includes("aborted")) {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Stream might already be closed or cancelled
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
controller.error(error);
|
||||||
|
} catch (e) { /* already closed */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import PropTypes from "prop-types";
|
||||||
import { Badge, Toggle } from "@/shared/components";
|
import { Badge, Toggle } from "@/shared/components";
|
||||||
import CooldownTimer from "./CooldownTimer";
|
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 [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||||
const proxyDropdownRef = useRef(null);
|
const proxyDropdownRef = useRef(null);
|
||||||
|
|
@ -114,6 +114,23 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||||
return "default";
|
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 (
|
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={`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">
|
<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 && (
|
{connection.globalPriority && (
|
||||||
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
||||||
)}
|
)}
|
||||||
|
{getOneByOneLabel() && (
|
||||||
|
<Badge variant={getOneByOneVariant()} size="sm">
|
||||||
|
{getOneByOneLabel()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasAnyProxy && (
|
{hasAnyProxy && (
|
||||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||||
|
|
@ -266,4 +288,8 @@ ConnectionRow.propTypes = {
|
||||||
onUpdateProxy: PropTypes.func,
|
onUpdateProxy: PropTypes.func,
|
||||||
onEdit: PropTypes.func.isRequired,
|
onEdit: PropTypes.func.isRequired,
|
||||||
onDelete: PropTypes.func.isRequired,
|
onDelete: PropTypes.func.isRequired,
|
||||||
|
oneByOneStatus: PropTypes.shape({
|
||||||
|
state: PropTypes.string,
|
||||||
|
error: PropTypes.string,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
@ -17,6 +17,12 @@ import AddApiKeyModal from "./AddApiKeyModal";
|
||||||
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
||||||
import AddCustomModelModal from "./AddCustomModelModal";
|
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() {
|
export default function ProviderDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -50,6 +56,12 @@ export default function ProviderDetailPage() {
|
||||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||||
const [confirmState, setConfirmState] = useState(null);
|
const [confirmState, setConfirmState] = useState(null);
|
||||||
const [showAgRiskModal, setShowAgRiskModal] = useState(false);
|
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 { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
|
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) => {
|
const handleDelete = async (id) => {
|
||||||
setConfirmState({
|
setConfirmState({
|
||||||
title: "Delete Connection",
|
title: "Delete Connection",
|
||||||
|
|
@ -646,6 +750,7 @@ export default function ProviderDetailPage() {
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(conn.id)}
|
onDelete={() => handleDelete(conn.id)}
|
||||||
|
oneByOneStatus={oneByOneResults[conn.id] || null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1063,6 +1168,30 @@ export default function ProviderDetailPage() {
|
||||||
Apply Proxy
|
Apply Proxy
|
||||||
</Button>
|
</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 */}
|
{/* Thinking config */}
|
||||||
{/* {thinkingConfig && (
|
{/* {thinkingConfig && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1147,6 +1276,22 @@ export default function ProviderDetailPage() {
|
||||||
</div>
|
</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}
|
{connectionsList}
|
||||||
{!isCompatible && (
|
{!isCompatible && (
|
||||||
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatResetTime, calculatePercentage } from "./utils";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { formatResetTime, getRemainingPercentage } from "./utils";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format reset time display (Today, 12:00 PM)
|
* Format reset time display (Today, 12:00 PM)
|
||||||
*/
|
*/
|
||||||
function formatResetTimeDisplay(resetTime) {
|
function formatResetTimeDisplay(resetTime) {
|
||||||
if (!resetTime) return null;
|
if (!resetTime) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(resetTime);
|
const date = new Date(resetTime);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const tomorrow = new Date(today);
|
const tomorrow = new Date(today);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
let dayStr = "";
|
let dayStr = "";
|
||||||
if (date >= today && date < tomorrow) {
|
if (date >= today && date < tomorrow) {
|
||||||
dayStr = "Today";
|
dayStr = "Today";
|
||||||
|
|
@ -23,13 +26,13 @@ function formatResetTimeDisplay(resetTime) {
|
||||||
} else {
|
} else {
|
||||||
dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeStr = date.toLocaleTimeString("en-US", {
|
const timeStr = date.toLocaleTimeString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: true
|
hour12: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${dayStr}, ${timeStr}`;
|
return `${dayStr}, ${timeStr}`;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -45,127 +48,212 @@ function getColorClasses(remainingPercentage) {
|
||||||
text: "text-green-600 dark:text-green-400",
|
text: "text-green-600 dark:text-green-400",
|
||||||
bg: "bg-green-500",
|
bg: "bg-green-500",
|
||||||
bgLight: "bg-green-500/10",
|
bgLight: "bg-green-500/10",
|
||||||
emoji: "🟢"
|
emoji: "🟢",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remainingPercentage >= 30) {
|
if (remainingPercentage >= 30) {
|
||||||
return {
|
return {
|
||||||
text: "text-yellow-600 dark:text-yellow-400",
|
text: "text-yellow-600 dark:text-yellow-400",
|
||||||
bg: "bg-yellow-500",
|
bg: "bg-yellow-500",
|
||||||
bgLight: "bg-yellow-500/10",
|
bgLight: "bg-yellow-500/10",
|
||||||
emoji: "🟡"
|
emoji: "🟡",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0-29% including 0% (out of quota) - show red
|
|
||||||
return {
|
return {
|
||||||
text: "text-red-600 dark:text-red-400",
|
text: "text-red-600 dark:text-red-400",
|
||||||
bg: "bg-red-500",
|
bg: "bg-red-500",
|
||||||
bgLight: "bg-red-500/10",
|
bgLight: "bg-red-500/10",
|
||||||
emoji: "🔴"
|
emoji: "🔴",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortQuotas(quotas, sortMode) {
|
||||||
|
if (sortMode === "remaining-asc") {
|
||||||
|
return [...quotas].sort((a, b) => a.remaining - b.remaining || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortMode === "remaining-desc") {
|
||||||
|
return [...quotas].sort((a, b) => b.remaining - a.remaining || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return quotas;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quota Table Component - Table-based display for quota data
|
* Quota Table Component - Table-based display for quota data
|
||||||
*/
|
*/
|
||||||
export default function QuotaTable({ quotas = [], compact = false }) {
|
export default function QuotaTable({
|
||||||
|
quotas = [],
|
||||||
|
compact = false,
|
||||||
|
sortMode = "default",
|
||||||
|
showSortLabel = false,
|
||||||
|
}) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const normalizedQuotas = useMemo(
|
||||||
|
() => quotas.map((quota, index) => ({
|
||||||
|
...quota,
|
||||||
|
index,
|
||||||
|
remaining: getRemainingPercentage(quota),
|
||||||
|
})),
|
||||||
|
[quotas],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedQuotas = useMemo(
|
||||||
|
() => sortQuotas(normalizedQuotas, sortMode),
|
||||||
|
[normalizedQuotas, sortMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sortedQuotas.length / PAGE_SIZE));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [sortMode, quotas]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage((currentPage) => Math.min(currentPage, totalPages));
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
if (!quotas || quotas.length === 0) {
|
if (!quotas || quotas.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentPageRows = sortedQuotas.slice(
|
||||||
|
(page - 1) * PAGE_SIZE,
|
||||||
|
page * PAGE_SIZE,
|
||||||
|
);
|
||||||
|
const pageStart = sortedQuotas.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
|
||||||
|
const pageEnd = Math.min(page * PAGE_SIZE, sortedQuotas.length);
|
||||||
|
|
||||||
const cellPad = compact ? "py-1 px-1.5" : "py-2 px-3";
|
const cellPad = compact ? "py-1 px-1.5" : "py-2 px-3";
|
||||||
const nameText = compact ? "text-[11px]" : "text-sm";
|
const nameText = compact ? "text-[11px]" : "text-sm";
|
||||||
const resetPrimary = compact ? "text-[11px]" : "text-sm";
|
const resetPrimary = compact ? "text-[11px]" : "text-sm";
|
||||||
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
|
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
|
||||||
|
const sortLabel = "Sorted by account remaining";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="space-y-2">
|
||||||
<table className="w-full table-fixed text-left">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<tbody>
|
<div className="text-[10px] text-text-muted">
|
||||||
{quotas.map((quota, index) => {
|
{sortedQuotas.length} quota{sortedQuotas.length > 1 ? "s" : ""}
|
||||||
const remaining = quota.remainingPercentage !== undefined
|
</div>
|
||||||
? Math.round(quota.remainingPercentage)
|
{showSortLabel && (
|
||||||
: calculatePercentage(quota.used, quota.total);
|
<div className="rounded-md border border-black/10 bg-black/[0.02] px-2 py-1 text-[10px] text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
{sortLabel}
|
||||||
const colors = getColorClasses(remaining);
|
</div>
|
||||||
const countdown = formatResetTime(quota.resetAt);
|
)}
|
||||||
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="overflow-x-auto">
|
||||||
<tr
|
<table className="w-full table-fixed text-left">
|
||||||
key={index}
|
<tbody>
|
||||||
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
{currentPageRows.map((quota) => {
|
||||||
>
|
const colors = getColorClasses(quota.remaining);
|
||||||
{/* Model Name with Status Emoji */}
|
const countdown = formatResetTime(quota.resetAt);
|
||||||
<td className={`${cellPad} w-[30%]`}>
|
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
|
||||||
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
|
||||||
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
|
||||||
{quota.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Limit (Progress + Numbers) */}
|
return (
|
||||||
<td className={`${cellPad} w-[45%]`}>
|
<tr
|
||||||
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
key={`${quota.name}-${quota.index}`}
|
||||||
{/* Progress bar - always show with border for visibility */}
|
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||||
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
>
|
||||||
remaining === 0 ? 'border-black/10 dark:border-white/10' : 'border-transparent'
|
<td className={`${cellPad} w-[30%]`}>
|
||||||
}`}>
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<div
|
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
||||||
className={`h-full transition-all duration-300 ${colors.bg}`}
|
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
||||||
style={{ width: `${Math.min(remaining, 100)}%` }}
|
{quota.name}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Numbers */}
|
|
||||||
<div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}>
|
|
||||||
<span className="text-text-muted">
|
|
||||||
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
|
|
||||||
</span>
|
|
||||||
<span className={`font-medium ${colors.text}`}>
|
|
||||||
{remaining}%
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Reset Time */}
|
<td className={`${cellPad} w-[45%]`}>
|
||||||
<td className={`${cellPad} w-[25%]`}>
|
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
||||||
{countdown !== "-" || resetDisplay ? (
|
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||||
compact ? (
|
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
|
||||||
<div
|
}`}>
|
||||||
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
<div
|
||||||
title={resetDisplay || ""}
|
className={`h-full transition-all duration-300 ${colors.bg}`}
|
||||||
>
|
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
|
||||||
{countdown !== "-" ? `in ${countdown}` : resetDisplay}
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}>
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
|
||||||
|
</span>
|
||||||
|
<span className={`font-medium ${colors.text}`}>
|
||||||
|
{quota.remaining}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className={`${cellPad} w-[25%]`}>
|
||||||
|
{countdown !== "-" || resetDisplay ? (
|
||||||
|
compact ? (
|
||||||
|
<div
|
||||||
|
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
||||||
|
title={resetDisplay || ""}
|
||||||
|
>
|
||||||
|
{countdown !== "-" ? `in ${countdown}` : resetDisplay}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{countdown !== "-" && (
|
||||||
|
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
||||||
|
in {countdown}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resetDisplay && (
|
||||||
|
<div className={`${resetSecondary} text-text-muted`}>
|
||||||
|
{resetDisplay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
||||||
{countdown !== "-" && (
|
)}
|
||||||
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
</td>
|
||||||
in {countdown}
|
</tr>
|
||||||
</div>
|
);
|
||||||
)}
|
})}
|
||||||
{resetDisplay && (
|
</tbody>
|
||||||
<div className={`${resetSecondary} text-text-muted`}>
|
</table>
|
||||||
{resetDisplay}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
{totalPages > 1 && (
|
||||||
</div>
|
<div className="rounded-md border border-black/10 bg-black/[0.02] px-2 py-1.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
)
|
<div className="flex items-center justify-between gap-2 text-[10px] text-text-muted">
|
||||||
) : (
|
<span>
|
||||||
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
Showing {pageStart}-{pageEnd} of {sortedQuotas.length}
|
||||||
)}
|
</span>
|
||||||
</td>
|
<span>
|
||||||
</tr>
|
Page {page} / {totalPages}
|
||||||
);
|
</span>
|
||||||
})}
|
</div>
|
||||||
</tbody>
|
<div className="mt-1.5 flex items-center justify-end gap-1">
|
||||||
</table>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="flex h-6 items-center rounded-md border border-black/10 px-2 text-[10px] text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((currentPage) => Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="flex h-6 items-center rounded-md border border-black/10 px-2 text-[10px] text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -75,6 +75,23 @@ export function calculatePercentage(used, total) {
|
||||||
return Math.round(((total - used) / total) * 100);
|
return Math.round(((total - used) / total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining percentage from a normalized quota row
|
||||||
|
* @param {Object} quota - Normalized quota object
|
||||||
|
* @returns {number} Remaining percentage (0-100)
|
||||||
|
*/
|
||||||
|
export function getRemainingPercentage(quota) {
|
||||||
|
if (quota?.remaining !== undefined) {
|
||||||
|
return Math.max(0, Math.round(quota.remaining));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quota?.remainingPercentage !== undefined) {
|
||||||
|
return Math.round(quota.remainingPercentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculatePercentage(quota?.used, quota?.total);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse provider-specific quota structures into normalized array
|
* Parse provider-specific quota structures into normalized array
|
||||||
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
|
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
|
||||||
|
|
@ -123,6 +140,7 @@ export function parseQuotaData(provider, data) {
|
||||||
name: quotaType,
|
name: quotaType,
|
||||||
used: quota.used || 0,
|
used: quota.used || 0,
|
||||||
total: quota.total || 0,
|
total: quota.total || 0,
|
||||||
|
remaining: quota.remaining,
|
||||||
resetAt: quota.resetAt || null,
|
resetAt: quota.resetAt || null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getProviderConnections } from "@/lib/localDb";
|
import { getProviderConnections } from "@/lib/localDb";
|
||||||
import { backfillCodexEmails } from "@/lib/oauth/providers";
|
import { backfillCodexEmails } from "@/lib/oauth/providers";
|
||||||
|
import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||||
|
|
||||||
// Whitelist: only safe metadata fields exposed to UI
|
|
||||||
const SAFE_FIELDS = [
|
const SAFE_FIELDS = [
|
||||||
"id", "provider", "authType", "name", "email", "displayName",
|
"id", "provider", "authType", "name", "email", "displayName",
|
||||||
"priority", "globalPriority", "isActive", "defaultModel",
|
"priority", "globalPriority", "isActive", "defaultModel",
|
||||||
|
|
@ -11,7 +11,6 @@ const SAFE_FIELDS = [
|
||||||
"createdAt", "updatedAt",
|
"createdAt", "updatedAt",
|
||||||
];
|
];
|
||||||
|
|
||||||
// providerSpecificData fields safe to expose (non-secret config only)
|
|
||||||
const SAFE_PSD_FIELDS = [
|
const SAFE_PSD_FIELDS = [
|
||||||
"baseUrl", "azureEndpoint", "deployment", "apiVersion", "accountId",
|
"baseUrl", "azureEndpoint", "deployment", "apiVersion", "accountId",
|
||||||
"region", "projectId", "resourceUrl", "proxyPoolId",
|
"region", "projectId", "resourceUrl", "proxyPoolId",
|
||||||
|
|
@ -20,9 +19,11 @@ const SAFE_PSD_FIELDS = [
|
||||||
"username", "firstName", "lastName", "authMethod", "authKind",
|
"username", "firstName", "lastName", "authMethod", "authKind",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
const MAX_PAGE_SIZE = 500;
|
||||||
|
|
||||||
function maskName(name) {
|
function maskName(name) {
|
||||||
if (typeof name !== "string" || name.length <= 16) return name;
|
if (typeof name !== "string" || name.length <= 16) return name;
|
||||||
// Names like "hahask-uDUOg90..." may embed API keys — mask if looks like key
|
|
||||||
if (/[a-zA-Z0-9_-]{32,}/.test(name)) return `${name.slice(0, 8)}***`;
|
if (/[a-zA-Z0-9_-]{32,}/.test(name)) return `${name.slice(0, 8)}***`;
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
@ -41,12 +42,83 @@ function sanitize(c) {
|
||||||
return safe;
|
return safe;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/providers/client - List connections for dashboard UI (whitelist only)
|
function isUsageEligible(connection) {
|
||||||
export async function GET() {
|
return USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && (
|
||||||
|
connection.authType === "oauth" || USAGE_APIKEY_PROVIDERS.includes(connection.provider)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortConnections(connections, sort) {
|
||||||
|
const list = [...connections];
|
||||||
|
|
||||||
|
if (sort === "provider") {
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
const orderA = USAGE_SUPPORTED_PROVIDERS.indexOf(a.provider);
|
||||||
|
const orderB = USAGE_SUPPORTED_PROVIDERS.indexOf(b.provider);
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return a.provider.localeCompare(b.provider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
const priorityA = a.priority ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const priorityB = b.priority ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (priorityA !== priorityB) return priorityA - priorityB;
|
||||||
|
return (a.provider || "").localeCompare(b.provider || "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
try {
|
try {
|
||||||
await backfillCodexEmails();
|
await backfillCodexEmails();
|
||||||
const connections = await getProviderConnections();
|
|
||||||
return NextResponse.json({ connections: connections.map(sanitize) });
|
const { searchParams } = new URL(request.url);
|
||||||
|
const provider = searchParams.get("provider") || "all";
|
||||||
|
const accountStatus = searchParams.get("accountStatus") || "all";
|
||||||
|
const sort = searchParams.get("sort") || "priority";
|
||||||
|
const page = parsePositiveInt(searchParams.get("page"), 1);
|
||||||
|
const pageSize = Math.min(parsePositiveInt(searchParams.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
||||||
|
|
||||||
|
const allConnections = await getProviderConnections();
|
||||||
|
const eligibleConnections = allConnections.filter(isUsageEligible);
|
||||||
|
const providerOptions = Array.from(new Set(eligibleConnections.map((conn) => conn.provider))).sort();
|
||||||
|
|
||||||
|
const providerFilteredConnections = eligibleConnections.filter((conn) => (
|
||||||
|
provider === "all" || conn.provider === provider
|
||||||
|
));
|
||||||
|
|
||||||
|
const accountFilteredConnections = providerFilteredConnections.filter((conn) => {
|
||||||
|
if (accountStatus === "active") return conn.isActive ?? true;
|
||||||
|
if (accountStatus === "inactive") return !(conn.isActive ?? true);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedConnections = sortConnections(accountFilteredConnections, sort);
|
||||||
|
const total = sortedConnections.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
const offset = (currentPage - 1) * pageSize;
|
||||||
|
const pageConnections = sortedConnections.slice(offset, offset + pageSize).map(sanitize);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
connections: pageConnections,
|
||||||
|
providerOptions,
|
||||||
|
pagination: {
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
eligibleConnections: eligibleConnections.length,
|
||||||
|
providerFilteredConnections: providerFilteredConnections.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error fetching providers for client:", error);
|
console.log("Error fetching providers for client:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,8 @@ function fallbackBin() {
|
||||||
function bgRefreshBin() {
|
function bgRefreshBin() {
|
||||||
if (binCache.refreshing) return;
|
if (binCache.refreshing) return;
|
||||||
binCache.refreshing = true;
|
binCache.refreshing = true;
|
||||||
execAsync("which tailscale 2>/dev/null || where tailscale 2>nul", { windowsHide: true, timeout: PROBE_TIMEOUT_MS })
|
const cmd = IS_WINDOWS ? "where tailscale 2>nul" : "which tailscale 2>/dev/null";
|
||||||
|
execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS })
|
||||||
.then(({ stdout }) => {
|
.then(({ stdout }) => {
|
||||||
const sys = stdout.trim();
|
const sys = stdout.trim();
|
||||||
binCache.value = sys || fallbackBin();
|
binCache.value = sys || fallbackBin();
|
||||||
|
|
@ -138,9 +139,10 @@ export function isTailscaleRunningStrict() {
|
||||||
const bin = getTailscaleBin();
|
const bin = getTailscaleBin();
|
||||||
if (!bin) return false;
|
if (!bin) return false;
|
||||||
try {
|
try {
|
||||||
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json 2>/dev/null`, {
|
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
timeout: PROBE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
const json = JSON.parse(out);
|
const json = JSON.parse(out);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue