feat: add proxy check button

This commit is contained in:
zhom
2025-11-25 14:37:17 +04:00
parent cc22384c54
commit 8a1943f84e
13 changed files with 717 additions and 69 deletions
+1
View File
@@ -1,6 +1,7 @@
"use client";
import { Geist, Geist_Mono } from "next/font/google";
import "@/styles/globals.css";
import "flag-icons/css/flag-icons.min.css";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
+25
View File
@@ -0,0 +1,25 @@
import { getFlagIconClass } from "@/lib/flag-utils";
import { cn } from "@/lib/utils";
interface FlagIconProps {
countryCode?: string;
className?: string;
squared?: boolean;
}
export function FlagIcon({
countryCode,
className,
squared = false,
}: FlagIconProps) {
if (!countryCode) {
return null;
}
const flagClass = getFlagIconClass(countryCode);
if (!flagClass) {
return null;
}
return <span className={cn(flagClass, squared && "fis", className)} />;
}
+134 -67
View File
@@ -61,9 +61,10 @@ import {
} from "@/lib/browser-utils";
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import type { BrowserProfile, ProxyCheckResult, StoredProxy } from "@/types";
import { LoadingButton } from "./loading-button";
import MultipleSelector, { type Option } from "./multiple-selector";
import { ProxyCheckButton } from "./proxy-check-button";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
@@ -99,6 +100,8 @@ type TableMeta = {
profileId: string,
proxyId: string | null,
) => void | Promise<void>;
checkingProxyId: string | null;
proxyCheckResults: Record<string, ProxyCheckResult>;
// Selection helpers
isProfileSelected: (id: string) => boolean;
@@ -437,6 +440,42 @@ export function ProfilesDataTable({
const [openProxySelectorFor, setOpenProxySelectorFor] = React.useState<
string | null
>(null);
const [checkingProxyId, setCheckingProxyId] = React.useState<string | null>(
null,
);
const [proxyCheckResults, setProxyCheckResults] = React.useState<
Record<string, ProxyCheckResult>
>({});
// Load cached check results for proxies
React.useEffect(() => {
const loadCachedResults = async () => {
const results: Record<string, ProxyCheckResult> = {};
const proxyIds = new Set<string>();
for (const profile of profiles) {
if (profile.proxy_id) {
proxyIds.add(profile.proxy_id);
}
}
for (const proxyId of proxyIds) {
try {
const cached = await invoke<ProxyCheckResult | null>(
"get_cached_proxy_check",
{ proxyId },
);
if (cached) {
results[proxyId] = cached;
}
} catch (_error) {
// Ignore errors
}
}
setProxyCheckResults(results);
};
if (profiles.length > 0) {
void loadCachedResults();
}
}, [profiles]);
const loadAllTags = React.useCallback(async () => {
try {
@@ -779,6 +818,8 @@ export function ProfilesDataTable({
proxyOverrides,
storedProxies,
handleProxySelection,
checkingProxyId,
proxyCheckResults,
// Selection helpers
isProfileSelected: (id: string) => selectedProfiles.includes(id),
@@ -823,6 +864,8 @@ export function ProfilesDataTable({
proxyOverrides,
storedProxies,
handleProxySelection,
checkingProxyId,
proxyCheckResults,
handleToggleAll,
handleCheckboxChange,
handleIconClick,
@@ -1275,90 +1318,114 @@ export function ProfilesDataTable({
}
return (
<Popover
open={isSelectorOpen}
onOpenChange={(open) =>
meta.setOpenProxySelectorFor(open ? profile.id : null)
}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"flex gap-2 items-center p-2 rounded",
isDisabled
? "opacity-60 cursor-not-allowed pointer-events-none"
: "cursor-pointer hover:bg-accent/50",
)}
>
<div className="flex gap-2 items-center">
<Popover
open={isSelectorOpen}
onOpenChange={(open) =>
meta.setOpenProxySelectorFor(open ? profile.id : null)
}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"text-sm",
!profileHasProxy && "text-muted-foreground",
"flex gap-2 items-center p-2 rounded",
isDisabled
? "opacity-60 cursor-not-allowed pointer-events-none"
: "cursor-pointer hover:bg-accent/50",
)}
>
{profileHasProxy
? trimName(displayName, 10)
: displayName}
</span>
</span>
</PopoverTrigger>
</TooltipTrigger>
{tooltipText && <TooltipContent>{tooltipText}</TooltipContent>}
</Tooltip>
{!isDisabled && (
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search proxies..." />
<CommandList>
<CommandEmpty>No proxies found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() =>
void meta.handleProxySelection(profile.id, null)
}
<span
className={cn(
"text-sm",
!profileHasProxy && "text-muted-foreground",
)}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === null
? "opacity-100"
: "opacity-0",
)}
/>
No Proxy
</CommandItem>
{meta.storedProxies.map((proxy) => (
{profileHasProxy
? trimName(displayName, 10)
: displayName}
</span>
</span>
</PopoverTrigger>
</TooltipTrigger>
{tooltipText && (
<TooltipContent>{tooltipText}</TooltipContent>
)}
</Tooltip>
{!isDisabled && (
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search proxies..." />
<CommandList>
<CommandEmpty>No proxies found.</CommandEmpty>
<CommandGroup>
<CommandItem
key={proxy.id}
value={proxy.name}
value="__none__"
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
void meta.handleProxySelection(profile.id, null)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id
effectiveProxyId === null
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
No Proxy
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
{meta.storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{profileHasProxy && effectiveProxy && !isDisabled && (
<ProxyCheckButton
proxy={effectiveProxy}
checkingProxyId={meta.checkingProxyId}
cachedResult={meta.proxyCheckResults[effectiveProxy.id]}
setCheckingProxyId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[effectiveProxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[effectiveProxy.id]: result,
}));
}}
/>
)}
</Popover>
</div>
);
},
},
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { FiCheck } from "react-icons/fi";
import { toast } from "sonner";
import { FlagIcon } from "@/components/flag-icon";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatRelativeTime } from "@/lib/flag-utils";
import type { ProxyCheckResult, StoredProxy } from "@/types";
interface ProxyCheckButtonProps {
proxy: StoredProxy;
checkingProxyId: string | null;
cachedResult?: ProxyCheckResult;
onCheckComplete?: (result: ProxyCheckResult) => void;
onCheckFailed?: (result: ProxyCheckResult) => void;
disabled?: boolean;
setCheckingProxyId?: (id: string | null) => void;
}
export function ProxyCheckButton({
proxy,
checkingProxyId,
cachedResult,
onCheckComplete,
onCheckFailed,
disabled = false,
setCheckingProxyId,
}: ProxyCheckButtonProps) {
const [localResult, setLocalResult] = React.useState<
ProxyCheckResult | undefined
>(cachedResult);
React.useEffect(() => {
setLocalResult(cachedResult);
}, [cachedResult]);
const handleCheck = React.useCallback(async () => {
if (checkingProxyId === proxy.id) return;
setCheckingProxyId?.(proxy.id);
try {
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: proxy.proxy_settings,
});
setLocalResult(result);
onCheckComplete?.(result);
// Show toast with location
const locationParts: string[] = [];
if (result.city) locationParts.push(result.city);
if (result.country) locationParts.push(result.country);
const location =
locationParts.length > 0 ? locationParts.join(", ") : "Unknown";
toast.success(
<div className="flex items-center gap-2">
Your proxy location is:
<span>{location}</span>
{result.country_code && (
<FlagIcon countryCode={result.country_code} className="text-base" />
)}
</div>,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Proxy check failed: ${errorMessage}`);
// Save failed check result
const failedResult: ProxyCheckResult = {
ip: "",
city: undefined,
country: undefined,
country_code: undefined,
timestamp: Math.floor(Date.now() / 1000),
is_valid: false,
};
setLocalResult(failedResult);
onCheckFailed?.(failedResult);
} finally {
setCheckingProxyId?.(null);
}
}, [
proxy,
checkingProxyId,
onCheckComplete,
onCheckFailed,
setCheckingProxyId,
]);
const isCurrentlyChecking = checkingProxyId === proxy.id;
const result = localResult;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center">
<FlagIcon countryCode={result.country_code} className="h-2.5" />
<FiCheck className="absolute bottom-[-6px] right-[-4px]" />
</span>
) : result && !result.is_valid ? (
<span className="text-red-600 text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isCurrentlyChecking ? (
<p>Checking proxy...</p>
) : result?.is_valid ? (
<div className="space-y-1">
<p className="flex items-center gap-1">
{result.country_code && (
<FlagIcon countryCode={result.country_code} />
)}
{[result.city, result.country].filter(Boolean).join(", ") ||
"Unknown"}
</p>
<p className="text-xs text-muted-foreground">IP: {result.ip}</p>
<p className="text-xs text-muted-foreground">
Checked {formatRelativeTime(result.timestamp)}
</p>
</div>
) : result && !result.is_valid ? (
<div>
<p>Proxy check failed</p>
<p className="text-xs text-muted-foreground">
Failed {formatRelativeTime(result.timestamp)}
</p>
</div>
) : (
<p>Check proxy validity</p>
)}
</TooltipContent>
</Tooltip>
);
}
+50 -2
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import * as React from "react";
import { useCallback, useState } from "react";
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
import { toast } from "sonner";
@@ -24,7 +25,8 @@ import {
} from "@/components/ui/tooltip";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { trimName } from "@/lib/name-utils";
import type { StoredProxy } from "@/types";
import type { ProxyCheckResult, StoredProxy } from "@/types";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
interface ProxyManagementDialogProps {
@@ -40,9 +42,37 @@ export function ProxyManagementDialog({
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [checkingProxyId, setCheckingProxyId] = useState<string | null>(null);
const [proxyCheckResults, setProxyCheckResults] = useState<
Record<string, ProxyCheckResult>
>({});
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
// Load cached check results on mount and when proxies change
React.useEffect(() => {
const loadCachedResults = async () => {
const results: Record<string, ProxyCheckResult> = {};
for (const proxy of storedProxies) {
try {
const cached = await invoke<ProxyCheckResult | null>(
"get_cached_proxy_check",
{ proxyId: proxy.id },
);
if (cached) {
results[proxy.id] = cached;
}
} catch (_error) {
// Ignore errors
}
}
setProxyCheckResults(results);
};
if (storedProxies.length > 0) {
void loadCachedResults();
}
}, [storedProxies]);
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
// Open in-app confirmation dialog
setProxyToDelete(proxy);
@@ -163,7 +193,25 @@ export function ProxyManagementDialog({
{proxyUsage[proxy.id] ?? 0}
</Badge>
</div>
<div className="flex flex-shrink-0 gap-1 items-center">
<div className="flex shrink-0 gap-1 items-center">
<ProxyCheckButton
proxy={proxy}
checkingProxyId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProxyId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
+34
View File
@@ -0,0 +1,34 @@
/**
* Get flag icon CSS class for a country code (ISO 3166-1 alpha-2)
*/
export function getFlagIconClass(countryCode: string): string {
if (!countryCode || countryCode.length !== 2) {
return "";
}
return `fi fi-${countryCode.toLowerCase()}`;
}
/**
* Format relative time (e.g., "2 minutes ago", "1 hour ago")
*/
export function formatRelativeTime(timestamp: number): string {
const now = Math.floor(Date.now() / 1000);
const secondsAgo = now - timestamp;
if (secondsAgo < 60) {
return "just now";
}
const minutesAgo = Math.floor(secondsAgo / 60);
if (minutesAgo < 60) {
return `${minutesAgo} minute${minutesAgo !== 1 ? "s" : ""} ago`;
}
const hoursAgo = Math.floor(minutesAgo / 60);
if (hoursAgo < 24) {
return `${hoursAgo} hour${hoursAgo !== 1 ? "s" : ""} ago`;
}
const daysAgo = Math.floor(hoursAgo / 24);
return `${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`;
}
+9
View File
@@ -25,6 +25,15 @@ export interface BrowserProfile {
tags?: string[];
}
export interface ProxyCheckResult {
ip: string;
city?: string;
country?: string;
country_code?: string;
timestamp: number;
is_valid: boolean;
}
export interface StoredProxy {
id: string;
name: string;