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
@@ -1,20 +1,23 @@
|
||||
"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)
|
||||
*/
|
||||
function formatResetTimeDisplay(resetTime) {
|
||||
if (!resetTime) return null;
|
||||
|
||||
|
||||
try {
|
||||
const date = new Date(resetTime);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
|
||||
let dayStr = "";
|
||||
if (date >= today && date < tomorrow) {
|
||||
dayStr = "Today";
|
||||
@@ -23,13 +26,13 @@ function formatResetTimeDisplay(resetTime) {
|
||||
} else {
|
||||
dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const timeStr = date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
|
||||
const timeStr = date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
|
||||
return `${dayStr}, ${timeStr}`;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -45,127 +48,212 @@ function getColorClasses(remainingPercentage) {
|
||||
text: "text-green-600 dark:text-green-400",
|
||||
bg: "bg-green-500",
|
||||
bgLight: "bg-green-500/10",
|
||||
emoji: "🟢"
|
||||
emoji: "🟢",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (remainingPercentage >= 30) {
|
||||
return {
|
||||
text: "text-yellow-600 dark:text-yellow-400",
|
||||
bg: "bg-yellow-500",
|
||||
bgLight: "bg-yellow-500/10",
|
||||
emoji: "🟡"
|
||||
emoji: "🟡",
|
||||
};
|
||||
}
|
||||
|
||||
// 0-29% including 0% (out of quota) - show red
|
||||
|
||||
return {
|
||||
text: "text-red-600 dark:text-red-400",
|
||||
bg: "bg-red-500",
|
||||
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
|
||||
*/
|
||||
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) {
|
||||
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 nameText = compact ? "text-[11px]" : "text-sm";
|
||||
const resetPrimary = compact ? "text-[11px]" : "text-sm";
|
||||
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
|
||||
const sortLabel = "Sorted by account remaining";
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed text-left">
|
||||
<tbody>
|
||||
{quotas.map((quota, index) => {
|
||||
const remaining = quota.remainingPercentage !== undefined
|
||||
? Math.round(quota.remainingPercentage)
|
||||
: calculatePercentage(quota.used, quota.total);
|
||||
|
||||
const colors = getColorClasses(remaining);
|
||||
const countdown = formatResetTime(quota.resetAt);
|
||||
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] text-text-muted">
|
||||
{sortedQuotas.length} quota{sortedQuotas.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
{showSortLabel && (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
{/* Model Name with Status Emoji */}
|
||||
<td className={`${cellPad} w-[30%]`}>
|
||||
<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>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed text-left">
|
||||
<tbody>
|
||||
{currentPageRows.map((quota) => {
|
||||
const colors = getColorClasses(quota.remaining);
|
||||
const countdown = formatResetTime(quota.resetAt);
|
||||
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
||||
|
||||
{/* Limit (Progress + Numbers) */}
|
||||
<td className={`${cellPad} w-[45%]`}>
|
||||
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
||||
{/* Progress bar - always show with border for visibility */}
|
||||
<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'
|
||||
}`}>
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${colors.bg}`}
|
||||
style={{ width: `${Math.min(remaining, 100)}%` }}
|
||||
/>
|
||||
</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}%
|
||||
return (
|
||||
<tr
|
||||
key={`${quota.name}-${quota.index}`}
|
||||
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className={`${cellPad} w-[30%]`}>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</td>
|
||||
|
||||
{/* Reset Time */}
|
||||
<td className={`${cellPad} w-[25%]`}>
|
||||
{countdown !== "-" || resetDisplay ? (
|
||||
compact ? (
|
||||
<div
|
||||
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
||||
title={resetDisplay || ""}
|
||||
>
|
||||
{countdown !== "-" ? `in ${countdown}` : resetDisplay}
|
||||
<td className={`${cellPad} w-[45%]`}>
|
||||
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
||||
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
|
||||
}`}>
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${colors.bg}`}
|
||||
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
|
||||
/>
|
||||
</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">
|
||||
{countdown !== "-" && (
|
||||
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
||||
in {countdown}
|
||||
</div>
|
||||
)}
|
||||
{resetDisplay && (
|
||||
<div className={`${resetSecondary} text-text-muted`}>
|
||||
{resetDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<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>
|
||||
Showing {pageStart}-{pageEnd} of {sortedQuotas.length}
|
||||
</span>
|
||||
<span>
|
||||
Page {page} / {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-end gap-1">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
|
||||
@@ -123,6 +140,7 @@ export function parseQuotaData(provider, data) {
|
||||
name: quotaType,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
remaining: quota.remaining,
|
||||
resetAt: quota.resetAt || null,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user