9router/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js
Zhen 0da61d8f7b
Improve dashboard responsive layouts (#805)
* Improve dashboard responsive layouts

* Improve proxy pools mobile layout

* Improve CLI tool card input responsiveness

---------

Co-authored-by: Delynn Assistant <zhen@dkzhen.org>
2026-05-01 16:34:07 +07:00

314 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Badge, Input, ModelSelectModal } from "@/shared/components";
import { TOOL_HOSTS } from "@/shared/constants/mitmToolHosts";
import Image from "next/image";
/**
* Per-tool MITM card — shows DNS status + model mappings.
* - Auto-saves model mapping on blur or modal select
* - Skips sudo modal if password is already cached
* - Model mappings can only be edited when DNS is active
*/
export default function MitmToolCard({
tool,
isExpanded,
onToggle,
serverRunning,
dnsActive,
hasCachedPassword,
apiKeys,
activeProviders,
hasActiveProviders,
modelAliases = {},
cloudEnabled,
onDnsChange,
}) {
const [loading, setLoading] = useState(false);
const [warning, setWarning] = useState(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [sudoPassword, setSudoPassword] = useState("");
const [pendingDnsAction, setPendingDnsAction] = useState(null);
const [modalError, setModalError] = useState(null);
const [modelMappings, setModelMappings] = useState({});
const [modalOpen, setModalOpen] = useState(false);
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
const mitmHosts = TOOL_HOSTS[tool.id] ?? [];
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows");
useEffect(() => {
if (isExpanded) loadSavedMappings();
}, [isExpanded]);
const loadSavedMappings = async () => {
try {
const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`);
if (res.ok) {
const data = await res.json();
if (Object.keys(data.aliases || {}).length > 0) setModelMappings(data.aliases);
}
} catch { /* ignore */ }
};
const saveMappings = useCallback(async (mappings) => {
try {
await fetch("/api/cli-tools/antigravity-mitm/alias", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool: tool.id, mappings }),
});
} catch { /* ignore */ }
}, [tool.id]);
const handleMappingBlur = (alias, value) => {
saveMappings({ ...modelMappings, [alias]: value });
};
const handleModelMappingChange = (alias, value) => {
setModelMappings(prev => ({ ...prev, [alias]: value }));
};
const openModelSelector = (alias) => {
setCurrentEditingAlias(alias);
setModalOpen(true);
};
const handleModelSelect = (model) => {
if (!currentEditingAlias || model.isPlaceholder) return;
const updated = { ...modelMappings, [currentEditingAlias]: model.value };
setModelMappings(updated);
saveMappings(updated);
};
const handleDnsToggle = () => {
if (!serverRunning) return;
const action = dnsActive ? "disable" : "enable";
if (isWindows || hasCachedPassword) {
doDnsAction(action, "");
} else {
setPendingDnsAction(action);
setShowPasswordModal(true);
setModalError(null);
}
};
const doDnsAction = async (action, password) => {
setLoading(true);
setWarning(null);
try {
const res = await fetch("/api/cli-tools/antigravity-mitm", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool: tool.id, action, sudoPassword: password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to toggle DNS");
if (action === "enable") {
setWarning(`Restart ${tool.name} to apply changes`);
}
setShowPasswordModal(false);
setSudoPassword("");
onDnsChange?.(data);
} catch { /* ignore */ } finally {
setLoading(false);
setPendingDnsAction(null);
}
};
const handleConfirmPassword = () => {
if (!sudoPassword.trim()) {
setModalError("Sudo password is required");
return;
}
doDnsAction(pendingDnsAction, sudoPassword);
};
return (
<>
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src={tool.image}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
/>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{!serverRunning ? (
<Badge variant="default" size="sm">Server off</Badge>
) : dnsActive ? (
<Badge variant="success" size="sm">Active</Badge>
) : (
<Badge variant="warning" size="sm">DNS off</Badge>
)}
</div>
<p className="text-xs text-text-muted sm:truncate">Intercept {tool.name} requests via MITM proxy</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>
expand_more
</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{/* Hosts */}
{mitmHosts.length > 0 && (
<div className="mt-2 rounded-md border border-border bg-surface/50 px-2 py-1.5">
<p className="text-[10px] font-medium tracking-wide text-text-main/80 mb-1">
Edit hosts file manually to add the following entries:
</p>
<ul className="list-none space-y-0.5 font-mono text-[10px] text-text-muted break-all">
{mitmHosts.map((h) => (
<li key={h}>127.0.0.1 {h}</li>
))}
</ul>
</div>
)}
{/* Info */}
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted px-1">
<p>Toggle DNS to redirect {tool.name} traffic through 9Router via MITM.</p>
{!dnsActive && (
<p className="text-amber-600 text-[10px] mt-1">
Enable DNS to edit model mappings
</p>
)}
</div>
{/* Model Mappings */}
{tool.defaultModels?.length > 0 && (
<div className="flex flex-col gap-2">
{tool.defaultModels.map((model) => (
<div key={model.alias} className="grid gap-1.5 sm:grid-cols-[9rem_auto_1fr_auto_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right">{model.name}</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<input
type="text"
value={modelMappings[model.alias] || ""}
onChange={(e) => handleModelMappingChange(model.alias, e.target.value)}
onBlur={(e) => handleMappingBlur(model.alias, e.target.value)}
placeholder="provider/model-id"
disabled={!dnsActive}
className={`min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5 ${!dnsActive ? "opacity-50 cursor-not-allowed" : ""}`}
/>
<button
onClick={() => openModelSelector(model.alias)}
disabled={!hasActiveProviders || !dnsActive}
className={`rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 ${hasActiveProviders && dnsActive ? "bg-surface border-border hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
{modelMappings[model.alias] && (
<button
onClick={() => {
handleModelMappingChange(model.alias, "");
saveMappings({ ...modelMappings, [model.alias]: "" });
}}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
))}
</div>
)}
{tool.defaultModels?.length === 0 && (
<p className="text-xs text-text-muted px-1">Model mappings will be available soon.</p>
)}
{/* Start / Stop DNS button */}
<div className="flex flex-col gap-2 sm:items-start">
{dnsActive ? (
<button
onClick={handleDnsToggle}
disabled={!serverRunning || loading}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-xs font-medium text-red-500 transition-colors hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:py-1.5"
>
<span className="material-symbols-outlined text-[16px]">stop_circle</span>
Stop DNS
</button>
) : (
<button
onClick={handleDnsToggle}
disabled={!serverRunning || loading}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:py-1.5"
>
<span className="material-symbols-outlined text-[16px]">play_circle</span>
Start DNS
</button>
)}
{/* Warning below button */}
{warning && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs text-amber-500">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span>{warning}</span>
</div>
)}
</div>
</div>
)}
</Card>
{/* Password Modal */}
{showPasswordModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="mx-4 flex w-full max-w-sm flex-col gap-4 rounded-xl border border-border bg-surface p-5 shadow-xl sm:p-6">
<h3 className="font-semibold text-text-main">Sudo Password Required</h3>
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
<p className="text-xs text-text-muted">Required to modify /etc/hosts and flush DNS cache</p>
</div>
<Input
type="password"
placeholder="Enter sudo password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !loading) handleConfirmPassword(); }}
/>
{modalError && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600">
<span className="material-symbols-outlined text-[14px]">error</span>
<span>{modalError}</span>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => { setShowPasswordModal(false); setSudoPassword(""); setModalError(null); }} disabled={loading}>
Cancel
</Button>
<Button variant="primary" size="sm" onClick={handleConfirmPassword} loading={loading}>
Confirm
</Button>
</div>
</div>
</div>
)}
{/* Model Select Modal */}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={`Select model for ${currentEditingAlias}`}
/>
</>
);
}