- Introduce default MITM router base URL and update related components to handle it.

- Add input for MITM router base URL in MitmServerCard component.
This commit is contained in:
decolua
2026-04-04 11:25:58 +07:00
parent 2e740ad7e4
commit d84489dba4
11 changed files with 559 additions and 266 deletions
@@ -70,14 +70,19 @@ function getColorClasses(remainingPercentage) {
/**
* Quota Table Component - Table-based display for quota data
*/
export default function QuotaTable({ quotas = [] }) {
export default function QuotaTable({ quotas = [], compact = false }) {
if (!quotas || quotas.length === 0) {
return null;
}
const cellPad = compact ? "py-1.5 px-2" : "py-2 px-3";
const nameText = compact ? "text-xs" : "text-sm";
const resetPrimary = compact ? "text-xs" : "text-sm";
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
return (
<div className="overflow-x-auto">
<table className="w-full table-fixed">
<table className="w-full table-fixed text-left">
<colgroup>
<col className="w-[30%]" /> {/* Model Name */}
<col className="w-[45%]" /> {/* Limit Progress */}
@@ -99,18 +104,20 @@ export default function QuotaTable({ quotas = [] }) {
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="py-2 px-3">
<div className="flex items-center gap-2">
<span className="text-xs">{colors.emoji}</span>
<span className="text-sm font-medium text-text-primary">{quota.name}</span>
<td className={cellPad}>
<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) */}
<td className="py-2 px-3">
<div className="space-y-1.5">
<td className={cellPad}>
<div className={compact ? "space-y-1" : "space-y-1.5"}>
{/* Progress bar - always show with border for visibility */}
<div className={`h-1.5 rounded-full overflow-hidden border ${colors.bgLight} ${
<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
@@ -120,7 +127,7 @@ export default function QuotaTable({ quotas = [] }) {
</div>
{/* Numbers */}
<div className="flex items-center justify-between text-xs">
<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>
@@ -132,22 +139,22 @@ export default function QuotaTable({ quotas = [] }) {
</td>
{/* Reset Time */}
<td className="py-2 px-3">
<td className={cellPad}>
{countdown !== "-" || resetDisplay ? (
<div className="space-y-0.5">
{countdown !== "-" && (
<div className="text-sm text-text-primary font-medium">
<div className={`${resetPrimary} text-text-primary font-medium`}>
in {countdown}
</div>
)}
{resetDisplay && (
<div className="text-xs text-text-muted">
<div className={`${resetSecondary} text-text-muted`}>
{resetDisplay}
</div>
)}
</div>
) : (
<div className="text-sm text-text-muted italic">N/A</div>
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)}
</td>
</tr>
@@ -2,11 +2,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import ProviderIcon from "@/shared/components/ProviderIcon";
import ProviderLimitCard from "./ProviderLimitCard";
import QuotaTable from "./QuotaTable";
import Toggle from "@/shared/components/Toggle";
import { parseQuotaData, calculatePercentage } from "./utils";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import { EditConnectionModal } from "@/shared/components";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
@@ -21,6 +22,11 @@ export default function ProviderLimits() {
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(60);
const [connectionsLoading, setConnectionsLoading] = useState(true);
const [deletingId, setDeletingId] = useState(null);
const [togglingId, setTogglingId] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [proxyPools, setProxyPools] = useState([]);
const intervalRef = useRef(null);
const countdownRef = useRef(null);
@@ -123,6 +129,97 @@ export default function ProviderLimits() {
[fetchQuota],
);
const handleDeleteConnection = useCallback(async (id) => {
if (!confirm("Delete this connection?")) return;
setDeletingId(id);
try {
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
if (res.ok) {
setConnections((prev) => prev.filter((c) => c.id !== id));
setQuotaData((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setLoading((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setErrors((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
} catch (error) {
console.error("Error deleting connection:", error);
} finally {
setDeletingId(null);
}
}, []);
const handleToggleConnectionActive = useCallback(async (id, isActive) => {
setTogglingId(id);
try {
const res = await fetch(`/api/providers/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive }),
});
if (res.ok) {
setConnections((prev) =>
prev.map((c) => (c.id === id ? { ...c, isActive } : c)),
);
}
} catch (error) {
console.error("Error updating connection status:", error);
} finally {
setTogglingId(null);
}
}, []);
const handleUpdateConnection = useCallback(
async (formData) => {
if (!selectedConnection?.id) return;
const connectionId = selectedConnection.id;
const provider = selectedConnection.provider;
try {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
await fetchConnections();
setShowEditModal(false);
setSelectedConnection(null);
if (USAGE_SUPPORTED_PROVIDERS.includes(provider)) {
await fetchQuota(connectionId, provider);
}
}
} catch (error) {
console.error("Error saving connection:", error);
}
},
[selectedConnection, fetchConnections, fetchQuota],
);
useEffect(() => {
let cancelled = false;
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
if (!cancelled && data?.proxyPools) {
setProxyPools(data.proxyPools);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
// Refresh all providers
const refreshAll = useCallback(async () => {
if (refreshingAll) return;
@@ -358,81 +455,148 @@ export default function ProviderLimits() {
</div>
</div>
{/* Provider Cards Grid */}
<div className="flex flex-col gap-4">
{/* Provider cards: 2 columns, compact */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{sortedConnections.map((conn) => {
const quota = quotaData[conn.id];
const isLoading = loading[conn.id];
const error = errors[conn.id];
// Use table layout for all providers
const isInactive = conn.isActive === false;
const rowBusy = deletingId === conn.id || togglingId === conn.id;
return (
<Card key={conn.id} padding="none">
<div className="p-6 border-b border-black/10 dark:border-white/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden">
<Card
key={conn.id}
padding="none"
className={`min-w-0 ${isInactive ? "opacity-60" : ""}`}
>
<div className="px-4 py-3 border-b border-black/10 dark:border-white/10">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 shrink-0 rounded-md flex items-center justify-center overflow-hidden">
<ProviderIcon
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
size={40}
size={32}
className="object-contain"
fallbackText={
conn.provider?.slice(0, 2).toUpperCase() || "PR"
}
/>
</div>
<div>
<h3 className="text-base font-semibold text-text-primary capitalize">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary capitalize truncate">
{conn.provider}
</h3>
{conn.name && (
<p className="text-sm text-text-muted">{conn.name}</p>
<p className="text-xs text-text-muted truncate">
{conn.name}
</p>
)}
</div>
</div>
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh quota"
>
<span
className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading || rowBusy}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh quota"
>
refresh
</span>
</button>
<span
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
<button
type="button"
onClick={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
disabled={rowBusy}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50"
title="Edit connection"
>
<span className="material-symbols-outlined text-[18px]">
edit
</span>
</button>
<button
type="button"
onClick={() => handleDeleteConnection(conn.id)}
disabled={rowBusy}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
title="Delete connection"
>
<span
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
>
delete
</span>
</button>
<div
className="inline-flex items-center pl-0.5"
title={
(conn.isActive ?? true)
? "Disable connection"
: "Enable connection"
}
>
<Toggle
size="sm"
checked={conn.isActive ?? true}
disabled={rowBusy}
onChange={(nextActive) =>
handleToggleConnectionActive(conn.id, nextActive)
}
/>
</div>
</div>
</div>
</div>
<div className="p-6">
<div className="px-3 py-3">
{isLoading ? (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[32px] animate-spin">
<div className="text-center py-5 text-text-muted">
<span className="material-symbols-outlined text-[28px] animate-spin">
progress_activity
</span>
</div>
) : error ? (
<div className="text-center py-8">
<span className="material-symbols-outlined text-[32px] text-red-500">
<div className="text-center py-5">
<span className="material-symbols-outlined text-[28px] text-red-500">
error
</span>
<p className="mt-2 text-sm text-text-muted">{error}</p>
<p className="mt-1.5 text-xs text-text-muted">{error}</p>
</div>
) : quota?.message ? (
<div className="text-center py-8">
<p className="text-sm text-text-muted">{quota.message}</p>
<div className="text-center py-5">
<p className="text-xs text-text-muted">{quota.message}</p>
</div>
) : (
<QuotaTable quotas={quota?.quotas} />
<QuotaTable quotas={quota?.quotas} compact />
)}
</div>
</Card>
);
})}
</div>
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
proxyPools={proxyPools}
onSave={handleUpdateConnection}
onClose={() => {
setShowEditModal(false);
setSelectedConnection(null);
}}
/>
</div>
);
}