-
{colors.emoji}
-
{quota.name}
+
+
+ {colors.emoji}
+
+ {quota.name}
+
{/* Limit (Progress + Numbers) */}
-
-
+
+
{/* Progress bar - always show with border for visibility */}
-
{/* Numbers */}
-
+
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
@@ -132,22 +139,22 @@ export default function QuotaTable({ quotas = [] }) {
{/* Reset Time */}
-
+
{countdown !== "-" || resetDisplay ? (
{countdown !== "-" && (
-
+
in {countdown}
)}
{resetDisplay && (
-
) : (
-
N/A
+
N/A
)}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
index 1c6324d..3f0dc59 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
@@ -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() {
- {/* Provider Cards Grid */}
-
+ {/* Provider cards: 2 columns, compact */}
+
{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 (
-
-
-
-
-
+
+
+
+
+
-
-
+
+
{conn.provider}
{conn.name && (
-
{conn.name}
+
+ {conn.name}
+
)}
-
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"
- >
-
+ 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
-
-
+
+ refresh
+
+
+
{
+ 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"
+ >
+
+ edit
+
+
+
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"
+ >
+
+ delete
+
+
+
+
+ handleToggleConnectionActive(conn.id, nextActive)
+ }
+ />
+
+
-
+
{isLoading ? (
-
-
+
+
progress_activity
) : error ? (
-
-
+
+
error
-
{error}
+
{error}
) : quota?.message ? (
-
-
{quota.message}
+
) : (
-
+
)}
);
})}
+
+ {
+ setShowEditModal(false);
+ setSelectedConnection(null);
+ }}
+ />
);
}
diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js
index 45dbcf9..9c758c7 100644
--- a/src/app/api/cli-tools/antigravity-mitm/route.js
+++ b/src/app/api/cli-tools/antigravity-mitm/route.js
@@ -17,6 +17,25 @@ import { getSettings, updateSettings } from "@/lib/localDb";
initDbHooks(getSettings, updateSettings);
+const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
+
+function normalizeMitmRouterBaseUrlInput(input) {
+ if (input == null || String(input).trim() === "") {
+ return DEFAULT_MITM_ROUTER_BASE;
+ }
+ const t = String(input).trim().replace(/\/+$/, "");
+ let u;
+ try {
+ u = new URL(t);
+ } catch {
+ throw new Error("Invalid MITM router URL");
+ }
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
+ throw new Error("MITM router URL must use http or https");
+ }
+ return t;
+}
+
const isWin = process.platform === "win32";
function getPassword(provided) {
@@ -37,6 +56,7 @@ function checkIsAdmin() {
export async function GET() {
try {
const status = await getMitmStatus();
+ const settings = await getSettings();
return NextResponse.json({
running: status.running,
pid: status.pid || null,
@@ -45,6 +65,9 @@ export async function GET() {
dnsStatus: status.dnsStatus || {},
hasCachedPassword: !!getCachedPassword() || !!(await loadEncryptedPassword()),
isAdmin: checkIsAdmin(),
+ mitmRouterBaseUrl:
+ (settings.mitmRouterBaseUrl && String(settings.mitmRouterBaseUrl).trim()) ||
+ DEFAULT_MITM_ROUTER_BASE,
});
} catch (error) {
console.log("Error getting MITM status:", error.message);
@@ -55,7 +78,7 @@ export async function GET() {
// POST - Start MITM server (cert + server, no DNS)
export async function POST(request) {
try {
- const { apiKey, sudoPassword } = await request.json();
+ const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json();
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
if (!apiKey || (!isWin && !pwd)) {
@@ -65,6 +88,18 @@ export async function POST(request) {
);
}
+ if (mitmRouterBaseUrl !== undefined && mitmRouterBaseUrl !== null) {
+ try {
+ const normalized = normalizeMitmRouterBaseUrlInput(mitmRouterBaseUrl);
+ await updateSettings({ mitmRouterBaseUrl: normalized });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e.message || "Invalid MITM router URL" },
+ { status: 400 },
+ );
+ }
+ }
+
const result = await startServer(apiKey, pwd);
if (!isWin) setCachedPassword(pwd);
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index f911a14..00983b6 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -6,6 +6,8 @@ import os from "node:os";
import fs from "node:fs";
import lockfile from "proper-lockfile";
+const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
+
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
// Get app name - fixed constant to avoid Windows path issues in standalone build
@@ -65,7 +67,8 @@ const defaultData = {
observabilityMaxJsonSize: 1024,
outboundProxyEnabled: false,
outboundProxyUrl: "",
- outboundNoProxy: ""
+ outboundNoProxy: "",
+ mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
},
pricing: {} // NEW: pricing configuration
};
@@ -101,6 +104,7 @@ function cloneDefaultData() {
outboundProxyEnabled: false,
outboundProxyUrl: "",
outboundNoProxy: "",
+ mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
},
pricing: {},
};
diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js
index 558b209..1268d16 100644
--- a/src/mitm/handlers/base.js
+++ b/src/mitm/handlers/base.js
@@ -1,6 +1,9 @@
const { log, err } = require("../logger");
-const ROUTER_BASE = "http://localhost:20128";
+const DEFAULT_LOCAL_ROUTER = "http://localhost:20128";
+const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER)
+ .trim()
+ .replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER;
const API_KEY = process.env.ROUTER_API_KEY;
/**
diff --git a/src/mitm/manager.js b/src/mitm/manager.js
index 31c362f..897145a 100644
--- a/src/mitm/manager.js
+++ b/src/mitm/manager.js
@@ -15,6 +15,27 @@ const { isCertExpired } = require("./cert/rootCA");
const { MITM_DIR } = require("./paths");
const { log, err } = require("./logger");
+const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
+
+function shellQuoteSingle(str) {
+ if (str == null || str === "") return "''";
+ return `'${String(str).replace(/'/g, "'\\''")}'`;
+}
+
+async function resolveMitmRouterBaseUrl() {
+ if (!_getSettings) return DEFAULT_MITM_ROUTER_BASE;
+ try {
+ const s = await _getSettings();
+ const raw = s && s.mitmRouterBaseUrl != null ? String(s.mitmRouterBaseUrl).trim() : "";
+ if (!raw) return DEFAULT_MITM_ROUTER_BASE;
+ const u = new URL(raw);
+ if (u.protocol !== "http:" && u.protocol !== "https:") return DEFAULT_MITM_ROUTER_BASE;
+ return raw.replace(/\/+$/, "");
+ } catch {
+ return DEFAULT_MITM_ROUTER_BASE;
+ }
+}
+
const MITM_PORT = 443;
const MITM_WIN_NODE_PORT = 8443;
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
@@ -427,7 +448,8 @@ async function startServer(apiKey, sudoPassword) {
}
// Step 2: Spawn server (Root CA already installed in Step 1.5)
- log("🚀 Starting server...");
+ const mitmRouterBase = await resolveMitmRouterBaseUrl();
+ log(`🚀 Starting server... (router: ${mitmRouterBase})`);
if (IS_WIN) {
// Kill any process using port 443 before spawning
try {
@@ -444,7 +466,12 @@ async function startServer(apiKey, sudoPassword) {
detached: false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
- env: { ...process.env, ROUTER_API_KEY: apiKey, NODE_ENV: "production" },
+ env: {
+ ...process.env,
+ ROUTER_API_KEY: apiKey,
+ NODE_ENV: "production",
+ MITM_ROUTER_BASE: mitmRouterBase,
+ },
}
);
@@ -452,7 +479,14 @@ async function startServer(apiKey, sudoPassword) {
} else if (isSudoAvailable()) {
// Pass HOME explicitly so os.homedir() resolves to the unprivileged user's home
// instead of /root when sudo resets the environment.
- const inlineCmd = `HOME='${os.homedir()}' ROUTER_API_KEY='${apiKey}' NODE_ENV='production' '${process.execPath}' '${SERVER_PATH}'`;
+ const inlineCmd = [
+ `HOME=${shellQuoteSingle(os.homedir())}`,
+ `ROUTER_API_KEY=${shellQuoteSingle(apiKey)}`,
+ `MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`,
+ "NODE_ENV=production",
+ shellQuoteSingle(process.execPath),
+ shellQuoteSingle(SERVER_PATH),
+ ].join(" ");
serverProcess = spawn(
"sudo", ["-S", "-E", "sh", "-c", inlineCmd],
{ detached: false, stdio: ["pipe", "pipe", "pipe"] }
@@ -464,7 +498,12 @@ async function startServer(apiKey, sudoPassword) {
serverProcess = spawn(process.execPath, [SERVER_PATH], {
detached: false,
stdio: ["ignore", "pipe", "pipe"],
- env: { ...process.env, ROUTER_API_KEY: apiKey, NODE_ENV: "production" },
+ env: {
+ ...process.env,
+ ROUTER_API_KEY: apiKey,
+ NODE_ENV: "production",
+ MITM_ROUTER_BASE: mitmRouterBase,
+ },
});
}
diff --git a/src/shared/components/EditConnectionModal.js b/src/shared/components/EditConnectionModal.js
new file mode 100644
index 0000000..f9c8420
--- /dev/null
+++ b/src/shared/components/EditConnectionModal.js
@@ -0,0 +1,204 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import Modal from "@/shared/components/Modal";
+import Input from "@/shared/components/Input";
+import Button from "@/shared/components/Button";
+import Badge from "@/shared/components/Badge";
+import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
+
+export default function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) {
+ const [formData, setFormData] = useState({
+ name: "",
+ priority: 1,
+ apiKey: "",
+ });
+ const [testing, setTesting] = useState(false);
+ const [testResult, setTestResult] = useState(null);
+ const [validating, setValidating] = useState(false);
+ const [validationResult, setValidationResult] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (connection) {
+ setFormData({
+ name: connection.name || "",
+ priority: connection.priority || 1,
+ apiKey: "",
+ });
+ setTestResult(null);
+ setValidationResult(null);
+ }
+ }, [connection]);
+
+ const isOAuth = connection?.authType === "oauth";
+ const isCompatible = connection
+ ? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider))
+ : false;
+
+ const handleTest = async () => {
+ if (!connection?.provider) return;
+ setTesting(true);
+ setTestResult(null);
+ try {
+ const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
+ const data = await res.json();
+ setTestResult(data.valid ? "success" : "failed");
+ } catch {
+ setTestResult("failed");
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ const handleValidate = async () => {
+ if (!connection?.provider || !formData.apiKey) return;
+ setValidating(true);
+ setValidationResult(null);
+ try {
+ const res = await fetch("/api/providers/validate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
+ });
+ const data = await res.json();
+ setValidationResult(data.valid ? "success" : "failed");
+ } catch {
+ setValidationResult("failed");
+ } finally {
+ setValidating(false);
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!connection) return;
+ setSaving(true);
+ try {
+ const updates = {
+ name: formData.name,
+ priority: formData.priority,
+ };
+ if (!isOAuth && formData.apiKey) {
+ updates.apiKey = formData.apiKey;
+ let isValid = validationResult === "success";
+ if (!isValid) {
+ try {
+ setValidating(true);
+ setValidationResult(null);
+ const res = await fetch("/api/providers/validate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
+ });
+ const data = await res.json();
+ isValid = !!data.valid;
+ setValidationResult(isValid ? "success" : "failed");
+ } catch {
+ setValidationResult("failed");
+ } finally {
+ setValidating(false);
+ }
+ }
+ if (isValid) {
+ updates.testStatus = "active";
+ updates.lastError = null;
+ updates.lastErrorAt = null;
+ }
+ }
+ await onSave(updates);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (!connection) return null;
+
+ return (
+
+
+
setFormData({ ...formData, name: e.target.value })}
+ placeholder={isOAuth ? "Account name" : "Production Key"}
+ />
+ {isOAuth && connection.email && (
+
+
Email
+
{connection.email}
+
+ )}
+
setFormData({ ...formData, priority: Number.parseInt(e.target.value, 10) || 1 })}
+ />
+
+ {!isOAuth && (
+ <>
+
+
setFormData({ ...formData, apiKey: e.target.value })}
+ placeholder="Enter new API key"
+ hint="Leave blank to keep the current API key."
+ className="flex-1"
+ />
+
+
+ {validating ? "Checking..." : "Check"}
+
+
+
+ {validationResult && (
+
+ {validationResult === "success" ? "Valid" : "Invalid"}
+
+ )}
+ >
+ )}
+
+ {!isCompatible && (
+
+
+ {testing ? "Testing..." : "Test Connection"}
+
+ {testResult && (
+
+ {testResult === "success" ? "Valid" : "Failed"}
+
+ )}
+
+ )}
+
+
+ {saving ? "Saving..." : "Save"}
+ Cancel
+
+
+
+ );
+}
+
+EditConnectionModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ connection: PropTypes.shape({
+ id: PropTypes.string,
+ name: PropTypes.string,
+ email: PropTypes.string,
+ priority: PropTypes.number,
+ authType: PropTypes.string,
+ provider: PropTypes.string,
+ providerSpecificData: PropTypes.object,
+ }),
+ proxyPools: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string,
+ name: PropTypes.string,
+ })),
+ onSave: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+};
diff --git a/src/shared/components/index.js b/src/shared/components/index.js
index f8f3e50..9ed963c 100644
--- a/src/shared/components/index.js
+++ b/src/shared/components/index.js
@@ -27,6 +27,7 @@ export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
+export { default as EditConnectionModal } from "./EditConnectionModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Tooltip } from "./Tooltip";