From 66f405353ce1969b928f6f67e8f991affa2d3e1a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 4 Jun 2025 08:47:54 +0000 Subject: [PATCH] Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue. --- new-frontend/.gitignore | 41 + new-frontend/README.md | 36 + new-frontend/eslint.config.mjs | 16 + new-frontend/next.config.ts | 7 + new-frontend/package.json | 29 + new-frontend/postcss.config.mjs | 5 + new-frontend/public/file.svg | 1 + new-frontend/public/globe.svg | 1 + new-frontend/public/next.svg | 1 + new-frontend/public/vercel.svg | 1 + new-frontend/public/window.svg | 1 + new-frontend/src/app/auth/.gitkeep | 0 new-frontend/src/app/auth/login/page.tsx | 164 + new-frontend/src/app/dashboard/.gitkeep | 0 new-frontend/src/app/dashboard/page.tsx | 190 ++ new-frontend/src/app/favicon.ico | Bin 0 -> 25931 bytes new-frontend/src/app/globals.css | 3 + new-frontend/src/app/inbounds/.gitkeep | 0 .../src/app/inbounds/[id]/clients/page.tsx | 297 ++ .../page.tsx.bak-reset-traffic-1748992960 | 267 ++ new-frontend/src/app/inbounds/add/page.tsx | 42 + .../src/app/inbounds/edit/[id]/page.tsx | 97 + new-frontend/src/app/inbounds/page.tsx | 194 ++ new-frontend/src/app/layout.tsx | 28 + new-frontend/src/app/page.tsx | 13 + new-frontend/src/app/settings/.gitkeep | 0 new-frontend/src/app/settings/page.tsx | 199 ++ .../src/components/dashboard/StatCard.tsx | 21 + .../dashboard/XrayGeoManagementModal.tsx | 191 ++ .../dashboard/XrayStatusIndicator.tsx | 45 + .../components/inbounds/ClientFormModal.tsx | 138 + .../components/inbounds/ClientShareModal.tsx | 112 + .../src/components/inbounds/InboundForm.tsx | 239 ++ .../inbounds/ProtocolClientListItem.tsx | 94 + .../inbounds/ProtocolClientSettings.tsx | 78 + .../stream_settings/StreamSettingsForm.tsx | 127 + new-frontend/src/components/layout/.gitkeep | 0 new-frontend/src/components/layout/Header.tsx | 36 + .../src/components/layout/MainLayout.tsx | 66 + .../src/components/layout/Sidebar.tsx | 69 + .../components/settings/OtherSettingsForm.tsx | 129 + .../components/settings/PanelSettingsForm.tsx | 133 + .../settings/SubscriptionSettingsForm.tsx | 163 + .../settings/TelegramSettingsForm.tsx | 136 + .../settings/UserAccountSettingsForm.tsx | 190 ++ .../src/components/shared/LogsModal.tsx | 118 + new-frontend/src/components/ui/.gitkeep | 0 .../src/components/ui/ProgressBar.tsx | 19 + new-frontend/src/context/AuthContext.tsx | 127 + new-frontend/src/hooks/.gitkeep | 0 new-frontend/src/lib/.gitkeep | 0 new-frontend/src/lib/formatters.ts | 33 + new-frontend/src/lib/subscriptionLink.ts | 134 + new-frontend/src/services/.gitkeep | 0 new-frontend/src/services/api.ts | 66 + new-frontend/src/styles/.gitkeep | 0 new-frontend/src/types/inbound.ts | 62 + new-frontend/src/types/settings.ts | 56 + new-frontend/tailwind.config.ts | 49 + new-frontend/tsconfig.json | 27 + new-frontend/yarn.lock | 3028 +++++++++++++++++ 61 files changed, 7319 insertions(+) create mode 100644 new-frontend/.gitignore create mode 100644 new-frontend/README.md create mode 100644 new-frontend/eslint.config.mjs create mode 100644 new-frontend/next.config.ts create mode 100644 new-frontend/package.json create mode 100644 new-frontend/postcss.config.mjs create mode 100644 new-frontend/public/file.svg create mode 100644 new-frontend/public/globe.svg create mode 100644 new-frontend/public/next.svg create mode 100644 new-frontend/public/vercel.svg create mode 100644 new-frontend/public/window.svg create mode 100644 new-frontend/src/app/auth/.gitkeep create mode 100644 new-frontend/src/app/auth/login/page.tsx create mode 100644 new-frontend/src/app/dashboard/.gitkeep create mode 100644 new-frontend/src/app/dashboard/page.tsx create mode 100644 new-frontend/src/app/favicon.ico create mode 100644 new-frontend/src/app/globals.css create mode 100644 new-frontend/src/app/inbounds/.gitkeep create mode 100644 new-frontend/src/app/inbounds/[id]/clients/page.tsx create mode 100644 new-frontend/src/app/inbounds/[id]/clients/page.tsx.bak-reset-traffic-1748992960 create mode 100644 new-frontend/src/app/inbounds/add/page.tsx create mode 100644 new-frontend/src/app/inbounds/edit/[id]/page.tsx create mode 100644 new-frontend/src/app/inbounds/page.tsx create mode 100644 new-frontend/src/app/layout.tsx create mode 100644 new-frontend/src/app/page.tsx create mode 100644 new-frontend/src/app/settings/.gitkeep create mode 100644 new-frontend/src/app/settings/page.tsx create mode 100644 new-frontend/src/components/dashboard/StatCard.tsx create mode 100644 new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx create mode 100644 new-frontend/src/components/dashboard/XrayStatusIndicator.tsx create mode 100644 new-frontend/src/components/inbounds/ClientFormModal.tsx create mode 100644 new-frontend/src/components/inbounds/ClientShareModal.tsx create mode 100644 new-frontend/src/components/inbounds/InboundForm.tsx create mode 100644 new-frontend/src/components/inbounds/ProtocolClientListItem.tsx create mode 100644 new-frontend/src/components/inbounds/ProtocolClientSettings.tsx create mode 100644 new-frontend/src/components/inbounds/stream_settings/StreamSettingsForm.tsx create mode 100644 new-frontend/src/components/layout/.gitkeep create mode 100644 new-frontend/src/components/layout/Header.tsx create mode 100644 new-frontend/src/components/layout/MainLayout.tsx create mode 100644 new-frontend/src/components/layout/Sidebar.tsx create mode 100644 new-frontend/src/components/settings/OtherSettingsForm.tsx create mode 100644 new-frontend/src/components/settings/PanelSettingsForm.tsx create mode 100644 new-frontend/src/components/settings/SubscriptionSettingsForm.tsx create mode 100644 new-frontend/src/components/settings/TelegramSettingsForm.tsx create mode 100644 new-frontend/src/components/settings/UserAccountSettingsForm.tsx create mode 100644 new-frontend/src/components/shared/LogsModal.tsx create mode 100644 new-frontend/src/components/ui/.gitkeep create mode 100644 new-frontend/src/components/ui/ProgressBar.tsx create mode 100644 new-frontend/src/context/AuthContext.tsx create mode 100644 new-frontend/src/hooks/.gitkeep create mode 100644 new-frontend/src/lib/.gitkeep create mode 100644 new-frontend/src/lib/formatters.ts create mode 100644 new-frontend/src/lib/subscriptionLink.ts create mode 100644 new-frontend/src/services/.gitkeep create mode 100644 new-frontend/src/services/api.ts create mode 100644 new-frontend/src/styles/.gitkeep create mode 100644 new-frontend/src/types/inbound.ts create mode 100644 new-frontend/src/types/settings.ts create mode 100644 new-frontend/tailwind.config.ts create mode 100644 new-frontend/tsconfig.json create mode 100644 new-frontend/yarn.lock diff --git a/new-frontend/.gitignore b/new-frontend/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/new-frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/new-frontend/README.md b/new-frontend/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/new-frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/new-frontend/eslint.config.mjs b/new-frontend/eslint.config.mjs new file mode 100644 index 00000000..c85fb67c --- /dev/null +++ b/new-frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/new-frontend/next.config.ts b/new-frontend/next.config.ts new file mode 100644 index 00000000..e9ffa308 --- /dev/null +++ b/new-frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/new-frontend/package.json b/new-frontend/package.json new file mode 100644 index 00000000..9b3af537 --- /dev/null +++ b/new-frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "new-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "15.3.3", + "qrcode.react": "^4.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/qrcode.react": "^3.0.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.3.3", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/new-frontend/postcss.config.mjs b/new-frontend/postcss.config.mjs new file mode 100644 index 00000000..c7bcb4b1 --- /dev/null +++ b/new-frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/new-frontend/public/file.svg b/new-frontend/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/new-frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/globe.svg b/new-frontend/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/new-frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/next.svg b/new-frontend/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/new-frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/vercel.svg b/new-frontend/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/new-frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/window.svg b/new-frontend/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/new-frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/src/app/auth/.gitkeep b/new-frontend/src/app/auth/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/auth/login/page.tsx b/new-frontend/src/app/auth/login/page.tsx new file mode 100644 index 00000000..a87da095 --- /dev/null +++ b/new-frontend/src/app/auth/login/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState, useEffect, FormEvent } from 'react'; +// No longer using useRouter here directly for push, AuthContext handles it. +import { post, ApiResponse } from '@/services/api'; // Added ApiResponse for explicit typing +import { useAuth } from '@/context/AuthContext'; // Import useAuth +import { useRouter, usePathname } from 'next/navigation'; // Still need for potential initial redirect, added usePathname + +const LoginPage: React.FC = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [twoFactorCode, setTwoFactorCode] = useState(''); + const [showTwoFactor, setShowTwoFactor] = useState(false); + // isLoading and error are now primarily handled by AuthContext, but local form states can still be useful + const [formIsLoading, setFormIsLoading] = useState(false); + const [formError, setFormError] = useState(null); + const [twoFactorCheckLoading, setTwoFactorCheckLoading] = useState(true); + + const { login, isLoading: authIsLoading, isAuthenticated } = useAuth(); // Use login from context + const router = useRouter(); // Still need for potential initial redirect if already logged in + const pathname = usePathname(); // Get current pathname + + useEffect(() => { + if (isAuthenticated) { + router.push('/dashboard'); + } + }, [isAuthenticated, router]); + + useEffect(() => { + const checkTwoFactorStatus = async () => { + setTwoFactorCheckLoading(true); + try { + const response = await post('/getTwoFactorEnable', {}); + if (response.success && typeof response.data === 'boolean') { + setShowTwoFactor(response.data); + } else { + console.warn('Could not determine 2FA status:', response.message); + setShowTwoFactor(false); + } + } catch (err) { + console.error('Error fetching 2FA status:', err); + setFormError('Could not check 2FA status.'); + setShowTwoFactor(false); + } finally { + setTwoFactorCheckLoading(false); + } + }; + if (!isAuthenticated) { // Only check if not already authenticated + checkTwoFactorStatus(); + } else { + setTwoFactorCheckLoading(false); // If authenticated, no need to check + } + }, [isAuthenticated]); // Rerun if isAuthenticated changes (e.g. logout then back to login) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setFormIsLoading(true); + setFormError(null); + + const response: ApiResponse = await login(username, password, showTwoFactor ? twoFactorCode : undefined); + + if (!response.success) { + setFormError(response.message || 'Login failed. Please check your credentials.'); + } + // Redirection is handled by AuthContext or the useEffect above + setFormIsLoading(false); + }; + + // If already authenticated and effect hasn't redirected yet, show loading or null + if (isAuthenticated && !pathname.startsWith('/auth')) { // check added for pathname + return

Loading dashboard...

; + } + + + return ( +
+
+

+ Login +

+
+
+ + setUsername(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> +
+ + {twoFactorCheckLoading &&

Checking 2FA status...

} + + {!twoFactorCheckLoading && showTwoFactor && ( +
+ + setTwoFactorCode(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> +
+ )} + + {formError && ( +

+ {formError} +

+ )} + +
+ +
+
+
+
+ ); +}; + +export default LoginPage; diff --git a/new-frontend/src/app/dashboard/.gitkeep b/new-frontend/src/app/dashboard/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/dashboard/page.tsx b/new-frontend/src/app/dashboard/page.tsx new file mode 100644 index 00000000..833f0221 --- /dev/null +++ b/new-frontend/src/app/dashboard/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { post } from '@/services/api'; // ApiResponse removed +import StatCard from '@/components/dashboard/StatCard'; +import ProgressBar from '@/components/ui/ProgressBar'; +import XrayStatusIndicator from '@/components/dashboard/XrayStatusIndicator'; +import LogsModal from '@/components/shared/LogsModal'; +import XrayGeoManagementModal from '@/components/dashboard/XrayGeoManagementModal'; // Import new modal +import { formatBytes, formatUptime, formatPercentage, toFixedIfNecessary } from '@/lib/formatters'; + +// Define button styles locally +const btnPrimaryStyles = "px-3 py-1.5 text-sm bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors"; +const btnDangerStyles = "px-3 py-1.5 text-sm bg-red-500 text-white font-semibold rounded-lg shadow-md hover:bg-red-600 disabled:opacity-50 transition-colors"; +const btnSecondaryStyles = "px-3 py-1.5 text-sm bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"; + + +interface SystemResource { current: number; total: number; } +interface NetIO { up: number; down: number; } +interface NetTraffic { sent: number; recv: number; } +interface PublicIP { ipv4: string; ipv6: string; } +interface AppStats { threads: number; mem: number; uptime: number; } +type XrayState = "running" | "stop" | "error"; +interface XrayStatusData { state: XrayState; errorMsg: string; version: string; } +interface ServerStatus { + cpu: number; cpuCores: number; logicalPro: number; cpuSpeedMhz: number; + mem: SystemResource; swap: SystemResource; disk: SystemResource; + xray: XrayStatusData; uptime: number; loads: number[]; + tcpCount: number; udpCount: number; netIO: NetIO; netTraffic: NetTraffic; + publicIP: PublicIP; appStats: AppStats; +} + +const DashboardPage: React.FC = () => { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const [status, setStatus] = useState(null); + const [isLoadingStatus, setIsLoadingStatus] = useState(true); + const [pollingError, setPollingError] = useState(null); + + const [actionLoading, setActionLoading] = useState<'' | 'restart' | 'stop'>(''); + const [actionMessage, setActionMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); + const [isXrayGeoModalOpen, setIsXrayGeoModalOpen] = useState(false); + + + const fetchStatus = useCallback(async (isInitialLoad = false) => { + if (!isAuthenticated) return; + if (isInitialLoad) { + setIsLoadingStatus(true); + // Clear polling error only on initial load attempt, so subsequent polling errors don't wipe the whole page if stale data is shown + setPollingError(null); + } + try { + const response = await post('/server/status', {}); + if (response.success && response.data) { + setStatus(response.data); + if(isInitialLoad) setPollingError(null); // Clear polling error if initial load succeeds + } else { + const errorMsg = response.message || 'Failed to fetch server status.'; + if (isInitialLoad) { + setStatus(null); // Clear status on initial load failure + setPollingError(errorMsg); // Set polling error to display page-level error + } else { + setPollingError(errorMsg); // For subsequent polling, just set the polling error to show warning + } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.'; + if (isInitialLoad) { + setStatus(null); + setPollingError(errorMessage); + } else { + setPollingError(errorMessage); + } + } finally { + if (isInitialLoad) setIsLoadingStatus(false); + } + }, [isAuthenticated]); + + useEffect(() => { + if (!authLoading && isAuthenticated) { + fetchStatus(true); + const intervalId = setInterval(() => fetchStatus(false), 5000); + return () => clearInterval(intervalId); + } else if (!authLoading && !isAuthenticated) { + setStatus(null); setIsLoadingStatus(false); + } + }, [isAuthenticated, authLoading, fetchStatus]); + + const handleXrayAction = async (action: 'restart' | 'stop') => { + setActionLoading(action); setActionMessage(null); + const endpoint = action === 'restart' ? '/server/restartXrayService' : '/server/stopXrayService'; + const successMessageText = action === 'restart' ? 'Xray restarted successfully.' : 'Xray stopped successfully.'; + const errorMessageText = action === 'restart' ? 'Failed to restart Xray.' : 'Failed to stop Xray.'; + let response; + try { + response = await post(endpoint, {}); // Explicitly type ApiResponse + if (response.success) { + setActionMessage({ type: 'success', message: response.message || successMessageText }); + } else { setActionMessage({ type: 'error', message: response.message || errorMessageText }); } + } catch (err) { setActionMessage({ type: 'error', message: err instanceof Error ? err.message : `Error ${action}ing Xray.` }); } + finally { + setActionLoading(''); + await fetchStatus(false); // Re-fetch status after action + if(response?.success) setTimeout(() => setActionMessage(null), 5000); + // Keep error message displayed until next action or refresh + } + }; + + const openLogsModal = () => { setActionMessage(null); setIsLogsModalOpen(true); }; + const openXrayGeoModal = () => { setActionMessage(null); setIsXrayGeoModalOpen(true); }; + + + if (authLoading || (isLoadingStatus && !status && !pollingError) ) { + return
Loading dashboard data...
; + } + if (pollingError && !status && !isLoadingStatus) { // Show error prominently if initial load failed and no stale data + return
Error: {pollingError}
; + } + + const getUsageColor = (percentage: number): string => { + if (percentage > 90) return 'bg-red-500'; + if (percentage > 75) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + return ( +
+

Dashboard

+ + {actionMessage && ( +
+ {actionMessage.message} +
+ )} + {pollingError && status && ( /* Polling error when data is stale */ +
+ Warning: Could not update status. Displaying last known data. Error: {pollingError} +
) + } + + {!status && !isLoadingStatus && !pollingError &&

No data available.

} + + {status && ( +
+ +

OS Uptime: {formatUptime(status.uptime)}

+

App Uptime: {formatUptime(status.appStats.uptime)}

+

Load Avg: {status.loads?.join(' / ')}

+

CPU Cores: {status.cpuCores} ({status.logicalPro} logical)

+

CPU Speed: {toFixedIfNecessary(status.cpuSpeedMhz,0)} MHz

+
+ + + + + + +
+ }> + + + +
{toFixedIfNecessary(status.cpu,1)}%
+
{formatBytes(status.mem.current)} / {formatBytes(status.mem.total)}{formatPercentage(status.mem.current, status.mem.total)}%
+ {status.swap.total > 0 ? (<>
{formatBytes(status.swap.current)} / {formatBytes(status.swap.total)}{formatPercentage(status.swap.current, status.swap.total)}%
) :

Not available

}
+
{formatBytes(status.disk.current)} / {formatBytes(status.disk.total)}{formatPercentage(status.disk.current, status.disk.total)}%
+

Upload: {formatBytes(status.netIO.up)}/s

Download: {formatBytes(status.netIO.down)}/s

Total Sent: {formatBytes(status.netTraffic.sent)}

Total Received: {formatBytes(status.netTraffic.recv)}

+

TCP: {status.tcpCount}

UDP: {status.udpCount}

+

IPv4: {status.publicIP.ipv4 || 'N/A'}

IPv6: {status.publicIP.ipv6 || 'N/A'}

+
+ )} + setIsLogsModalOpen(false)} /> + {/* Conditional rendering for XrayGeoManagementModal to ensure 'status' and 'status.xray' are available */} + {isXrayGeoModalOpen && status?.xray && setIsXrayGeoModalOpen(false)} + currentXrayVersion={status.xray.version} // Pass version directly + onActionComplete={() => fetchStatus(true)} // Re-fetch status on completion + />} + + ); +}; +export default DashboardPage; diff --git a/new-frontend/src/app/favicon.ico b/new-frontend/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/new-frontend/src/app/globals.css b/new-frontend/src/app/globals.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/new-frontend/src/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/new-frontend/src/app/inbounds/.gitkeep b/new-frontend/src/app/inbounds/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/inbounds/[id]/clients/page.tsx b/new-frontend/src/app/inbounds/[id]/clients/page.tsx new file mode 100644 index 00000000..d422f5a0 --- /dev/null +++ b/new-frontend/src/app/inbounds/[id]/clients/page.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; +import { post } from '@/services/api'; +import { Inbound, ClientSetting, Protocol } from '@/types/inbound'; +import { formatBytes } from '@/lib/formatters'; +import ClientFormModal from '@/components/inbounds/ClientFormModal'; +import ClientShareModal from '@/components/inbounds/ClientShareModal'; // Import Share Modal + +// Define button styles locally for consistency +const btnPrimaryStyles = "px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors text-sm"; +const btnTextPrimaryStyles = "text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 disabled:opacity-50"; +const btnTextDangerStyles = "text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"; +const btnTextWarningStyles = "text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 disabled:opacity-50"; +const btnTextIndigoStyles = "text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 disabled:opacity-50"; + + +interface DisplayClient extends ClientSetting { + up?: number; down?: number; actualTotal?: number; + actualExpiryTime?: number; enableClientStat?: boolean; + inboundId?: number; clientTrafficId?: number; + originalIndex?: number; +} + +enum ClientAction { NONE = '', DELETING = 'deleting', RESETTING_TRAFFIC = 'resetting_traffic' } + +const ManageClientsPage: React.FC = () => { + const params = useParams(); + const router = useRouter(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const inboundId = parseInt(params.id as string, 10); + + const [inbound, setInbound] = useState(null); + const [displayClients, setDisplayClients] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [pageError, setPageError] = useState(null); + + const [isClientFormModalOpen, setIsClientFormModalOpen] = useState(false); + const [editingClient, setEditingClient] = useState(null); + const [clientFormModalError, setClientFormModalError] = useState(null); + const [clientFormModalLoading, setClientFormModalLoading] = useState(false); + + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const [sharingClient, setSharingClient] = useState(null); + + const [currentAction, setCurrentAction] = useState(ClientAction.NONE); + const [actionTargetEmail, setActionTargetEmail] = useState(null); + const [actionError, setActionError] = useState(null); + + const fetchInboundAndClients = useCallback(async () => { + if (!isAuthenticated || !inboundId) return; + setIsLoading(true); setPageError(null); setActionError(null); + try { + const response = await post('/inbound/list', {}); + if (response.success && response.data) { + const currentInbound = response.data.find(ib => ib.id === inboundId); + if (currentInbound) { + setInbound(currentInbound); + let definedClients: ClientSetting[] = []; + if (currentInbound.protocol === 'vmess' || currentInbound.protocol === 'vless' || currentInbound.protocol === 'trojan') { + if (currentInbound.settings) { + try { + const parsedSettings = JSON.parse(currentInbound.settings); + if (Array.isArray(parsedSettings.clients)) definedClients = parsedSettings.clients; + } catch (e) { console.error("Error parsing settings:", e); setPageError("Could not parse client definitions."); } + } + } + const mergedClients: DisplayClient[] = definedClients.map((dc, index) => { + const stat = currentInbound.clientStats?.find(cs => cs.email === dc.email); + return { + ...dc, + up: stat?.up, down: stat?.down, actualTotal: stat?.total, + actualExpiryTime: stat?.expiryTime, enableClientStat: stat?.enable, + inboundId: stat?.inboundId, clientTrafficId: stat?.id, + originalIndex: index + }; + }); + currentInbound.clientStats?.forEach(stat => { + if (!mergedClients.find(mc => mc.email === stat.email)) { + mergedClients.push({ + email: stat.email, up: stat.up, down: stat.down, actualTotal: stat.total, + actualExpiryTime: stat.expiryTime, enableClientStat: stat.enable, + inboundId: stat.inboundId, clientTrafficId: stat.id, + }); + } + }); + setDisplayClients(mergedClients); + } else { setPageError('Inbound not found.'); setInbound(null); setDisplayClients([]); } + } else { setPageError(response.message || 'Failed to fetch inbound data.'); setInbound(null); setDisplayClients([]); } + } catch (err) { setPageError(err instanceof Error ? err.message : 'An unknown error occurred.'); setInbound(null); setDisplayClients([]); } + finally { setIsLoading(false); } + }, [isAuthenticated, inboundId]); + + useEffect(() => { + if (!authLoading && isAuthenticated) fetchInboundAndClients(); + else if (!authLoading && !isAuthenticated) { setIsLoading(false); router.push('/auth/login'); } + }, [isAuthenticated, authLoading, fetchInboundAndClients, router]); + + const openAddModal = () => { + setEditingClient(null); setClientFormModalError(null); setIsClientFormModalOpen(true); + }; + const openEditModal = (client: DisplayClient) => { + setEditingClient(client); setClientFormModalError(null); setIsClientFormModalOpen(true); + }; + const openShareModal = (client: ClientSetting) => { // ClientSetting is enough for link generation + setSharingClient(client); setIsShareModalOpen(true); + }; + + + const handleClientFormSubmit = async (submittedClientData: ClientSetting) => { + if (!inbound) { setClientFormModalError("Inbound data not available."); return; } + setClientFormModalLoading(true); setClientFormModalError(null); setActionError(null); + try { + let currentSettings: { clients?: ClientSetting[], [key:string]: unknown } = {}; + try { currentSettings = JSON.parse(inbound.settings || '{}'); } + catch (e) { console.error("Corrupted inbound settings:", e); currentSettings.clients = []; } + + const updatedClients = [...(currentSettings.clients || [])]; + let clientIdentifierForApi: string | undefined; + + if (editingClient && editingClient.originalIndex !== undefined) { + updatedClients[editingClient.originalIndex] = submittedClientData; + clientIdentifierForApi = inbound.protocol === 'trojan' ? editingClient.password : editingClient.id; + if (!clientIdentifierForApi && submittedClientData.password && inbound.protocol === 'trojan') clientIdentifierForApi = submittedClientData.password; + if (!clientIdentifierForApi && submittedClientData.id && (inbound.protocol === 'vmess' || inbound.protocol === 'vless')) clientIdentifierForApi = submittedClientData.id; + } else { + if (updatedClients.some(c => c.email === submittedClientData.email)) { + setClientFormModalError(`Client with email "${submittedClientData.email}" already exists.`); + setClientFormModalLoading(false); return; + } + updatedClients.push(submittedClientData); + } + + const updatedSettingsJson = JSON.stringify({ ...currentSettings, clients: updatedClients }, null, 2); + const payloadForApi: Partial = { ...inbound, id: inbound.id, settings: updatedSettingsJson }; + + let response; + if (editingClient) { + if (!clientIdentifierForApi) { + clientIdentifierForApi = inbound.protocol === 'trojan' ? editingClient?.password : editingClient?.id; + } + if (!clientIdentifierForApi) { + setClientFormModalError("Original client identifier for API is missing for editing."); + setClientFormModalLoading(false); return; + } + response = await post(`/inbound/updateClient/${clientIdentifierForApi}`, payloadForApi); + } else { + response = await post('/inbound/addClient', payloadForApi); + } + + if (response.success) { + setIsClientFormModalOpen(false); setEditingClient(null); + await fetchInboundAndClients(); + } else { setClientFormModalError(response.message || `Failed to ${editingClient ? 'update' : 'add'} client.`); } + } catch (err) { setClientFormModalError(err instanceof Error ? err.message : `An error occurred.`); } + finally { setClientFormModalLoading(false); } + }; + + const handleDeleteClient = async (clientToDelete: DisplayClient) => { + if (!inbound || !clientToDelete.email) { setActionError("Client or Inbound data is missing."); return; } + const clientApiId = clientToDelete.email; + if (!window.confirm(`Delete client: ${clientToDelete.email}?`)) return; + setCurrentAction(ClientAction.DELETING); setActionTargetEmail(clientToDelete.email); setActionError(null); + try { + const response = await post(`/inbound/${inbound.id}/delClient/${clientApiId}`, {}); + if (response.success) { await fetchInboundAndClients(); } + else { setActionError(response.message || "Failed to delete client."); } + } catch (err) { setActionError(err instanceof Error ? err.message : "Error deleting client."); } + finally { setCurrentAction(ClientAction.NONE); setActionTargetEmail(null); } + }; + + const handleResetClientTraffic = async (clientToReset: DisplayClient) => { + if (!inbound || !clientToReset.email) { setActionError("Client/Inbound data missing."); return; } + if (!window.confirm(`Reset traffic for: ${clientToReset.email}?`)) return; + setCurrentAction(ClientAction.RESETTING_TRAFFIC); setActionTargetEmail(clientToReset.email); setActionError(null); + try { + const response = await post(`/inbound/${inbound.id}/resetClientTraffic/${clientToReset.email}`, {}); + if (response.success) { await fetchInboundAndClients(); } + else { setActionError(response.message || "Failed to reset traffic."); } + } catch (err) { setActionError(err instanceof Error ? err.message : "Error resetting traffic."); } + finally { setCurrentAction(ClientAction.NONE); setActionTargetEmail(null); } + }; + + const getClientIdentifier = (client: DisplayClient, proto: Protocol | undefined): string => proto === 'trojan' ? client.password || 'N/A' : client.id || 'N/A'; + const getClientIdentifierLabel = (proto: Protocol | undefined): string => proto === 'trojan' ? 'Password' : 'UUID'; + + if (isLoading || authLoading) return
Loading...
; + if (pageError && !inbound) return
Error: {pageError}
; + if (!inbound && !isLoading) return
Inbound not found.
; + + const canManageClients = inbound && (inbound.protocol === 'vmess' || inbound.protocol === 'vless' || inbound.protocol === 'trojan' || inbound.protocol === 'shadowsocks'); + + return ( +
+
+

+ Clients for: {inbound?.remark || `#${inbound?.id}`} + ({inbound?.protocol}) +

+ {canManageClients && (inbound?.protocol !== 'shadowsocks') && + () + } +
+ + {pageError && inbound &&
Page load error: {pageError} (stale data)
} + {actionError &&
Action Error: {actionError}
} + + {displayClients.length === 0 && !pageError && inbound?.protocol !== 'shadowsocks' &&

No clients configured for this inbound.

} + {inbound?.protocol === 'shadowsocks' && +

+ For Shadowsocks, client configuration (method and password) is part of the main inbound settings. + The QR code / subscription link below uses these global settings. +

+ } + + {(displayClients.length > 0 || inbound?.protocol === 'shadowsocks') && ( +
+ + + + + {(inbound?.protocol !== 'shadowsocks') && } + + + + + + + + + { (inbound?.protocol === 'shadowsocks') ? ( + + + + + + + + + ) : displayClients.map((client) => { + const clientActionTargetId = client.email; + const isCurrentActionTarget = actionTargetEmail === clientActionTargetId; + return ( + + + + + + + + + + )})} + +
Email / Identifier{getClientIdentifierLabel(inbound?.protocol)}Traffic (Up/Down)QuotaExpiryStatusActions
{inbound.remark || 'Shadowsocks Settings'}{formatBytes(inbound.up || 0)} / {formatBytes(inbound.down || 0)}{inbound.total > 0 ? formatBytes(inbound.total) : 'Unlimited'}{inbound.expiryTime > 0 ? new Date(inbound.expiryTime).toLocaleDateString() : 'Never'} + {inbound.enable ? Enabled : Disabled } + + + {/* No Edit/Delete/Reset for SS "client" as it's part of inbound config */} +
{client.email}{getClientIdentifier(client, inbound?.protocol)}{formatBytes(client.up || 0)} / {formatBytes(client.down || 0)} + { (client.totalGB !== undefined && client.totalGB > 0) ? formatBytes(client.totalGB * 1024 * 1024 * 1024) : (client.actualTotal !== undefined && client.actualTotal > 0) ? formatBytes(client.actualTotal) : 'Unlimited' } + + {client.actualExpiryTime && client.actualExpiryTime > 0 ? new Date(client.actualExpiryTime).toLocaleDateString() : client.expiryTime && client.expiryTime > 0 ? new Date(client.expiryTime).toLocaleDateString() + " (Def)" : 'Never'} + + {client.enableClientStat === undefined ? 'N/A' : client.enableClientStat ? + Enabled : + Disabled + } + + + + + +
+
+ )} + {isClientFormModalOpen && inbound && ( + { setIsClientFormModalOpen(false); setEditingClient(null); }} + onSubmit={handleClientFormSubmit} protocol={inbound.protocol as Protocol} + existingClient={editingClient} formError={clientFormModalError} isLoading={clientFormModalLoading} + /> + )} + {isShareModalOpen && inbound && ( + setIsShareModalOpen(false)} + inbound={inbound} client={sharingClient} + /> + )} +
+ ); +}; +export default ManageClientsPage; diff --git a/new-frontend/src/app/inbounds/[id]/clients/page.tsx.bak-reset-traffic-1748992960 b/new-frontend/src/app/inbounds/[id]/clients/page.tsx.bak-reset-traffic-1748992960 new file mode 100644 index 00000000..c5b2e3a0 --- /dev/null +++ b/new-frontend/src/app/inbounds/[id]/clients/page.tsx.bak-reset-traffic-1748992960 @@ -0,0 +1,267 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; +import { post } from '@/services/api'; +import { Inbound, ClientSetting, Protocol } from '@/types/inbound'; +import { formatBytes } from '@/lib/formatters'; +import ClientFormModal from '@/components/inbounds/ClientFormModal'; + +// Define button styles locally for consistency +const btnPrimaryStyles = "px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors text-sm"; +const btnTextPrimaryStyles = "text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 disabled:opacity-50"; +const btnTextDangerStyles = "text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"; +const btnTextWarningStyles = "text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 disabled:opacity-50"; +const btnTextIndigoStyles = "text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 disabled:opacity-50"; + + +interface DisplayClient extends ClientSetting { + up?: number; down?: number; actualTotal?: number; + actualExpiryTime?: number; enableClientStat?: boolean; + inboundId?: number; clientTrafficId?: number; + originalIndex?: number; +} + +const ManageClientsPage: React.FC = () => { + const params = useParams(); + const router = useRouter(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + const inboundId = parseInt(params.id as string, 10); + + const [inbound, setInbound] = useState(null); + const [displayClients, setDisplayClients] = useState([]); + const [isLoading, setIsLoading] = useState(true); // Page loading state + const [pageError, setPageError] = useState(null); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingClient, setEditingClient] = useState(null); + const [modalError, setModalError] = useState(null); + const [modalLoading, setModalLoading] = useState(false); // For add/edit operations + + const [actionClientId, setActionClientId] = useState(null); // For delete loading state + const [actionError, setActionError] = useState(null); // For errors during actions like delete + + const fetchInboundAndClients = useCallback(async () => { + if (!isAuthenticated || !inboundId) return; + setIsLoading(true); setPageError(null); setActionError(null); // Clear action error on refresh + try { + const response = await post('/inbound/list', {}); + if (response.success && response.data) { + const currentInbound = response.data.find(ib => ib.id === inboundId); + if (currentInbound) { + setInbound(currentInbound); + let definedClients: ClientSetting[] = []; + if (currentInbound.protocol === 'vmess' || currentInbound.protocol === 'vless' || currentInbound.protocol === 'trojan') { + if (currentInbound.settings) { + try { + const parsedSettings = JSON.parse(currentInbound.settings); + if (Array.isArray(parsedSettings.clients)) definedClients = parsedSettings.clients; + } catch (e) { console.error("Error parsing settings:", e); setPageError("Could not parse client definitions."); } + } + } + const mergedClients: DisplayClient[] = definedClients.map((dc, index) => { + const stat = currentInbound.clientStats?.find(cs => cs.email === dc.email); + return { + ...dc, + up: stat?.up, down: stat?.down, actualTotal: stat?.total, + actualExpiryTime: stat?.expiryTime, enableClientStat: stat?.enable, + inboundId: stat?.inboundId, clientTrafficId: stat?.id, + originalIndex: index + }; + }); + currentInbound.clientStats?.forEach(stat => { + if (!mergedClients.find(mc => mc.email === stat.email)) { + mergedClients.push({ + email: stat.email, up: stat.up, down: stat.down, actualTotal: stat.total, + actualExpiryTime: stat.expiryTime, enableClientStat: stat.enable, + inboundId: stat.inboundId, clientTrafficId: stat.id, + }); + } + }); + setDisplayClients(mergedClients); + } else { setPageError('Inbound not found.'); setInbound(null); setDisplayClients([]); } + } else { setPageError(response.message || 'Failed to fetch inbound data.'); setInbound(null); setDisplayClients([]); } + } catch (err) { setPageError(err instanceof Error ? err.message : 'An unknown error occurred.'); setInbound(null); setDisplayClients([]); } + finally { setIsLoading(false); } + }, [isAuthenticated, inboundId]); + + useEffect(() => { + if (!authLoading && isAuthenticated) fetchInboundAndClients(); + else if (!authLoading && !isAuthenticated) { setIsLoading(false); router.push('/auth/login'); } + }, [isAuthenticated, authLoading, fetchInboundAndClients, router]); + + const openAddModal = () => { + setEditingClient(null); setModalError(null); setIsModalOpen(true); + }; + + const openEditModal = (client: DisplayClient) => { + setEditingClient(client); setModalError(null); setIsModalOpen(true); + }; + + const handleClientFormSubmit = async (submittedClientData: ClientSetting) => { + if (!inbound) { setModalError("Inbound data not available."); return; } + setModalLoading(true); setModalError(null); setActionError(null); + try { + let currentSettings: { clients?: ClientSetting[], [key:string]: unknown } = {}; + try { currentSettings = JSON.parse(inbound.settings || '{}'); } + catch (e) { console.error("Corrupted inbound settings:", e); currentSettings.clients = []; } + + const updatedClients = [...(currentSettings.clients || [])]; // Changed to const + // clientIdentifierForApi and related logic removed + + if (editingClient && editingClient.originalIndex !== undefined) { + updatedClients[editingClient.originalIndex] = submittedClientData; + } else { // Add mode (or if originalIndex is somehow undefined for an edit - fallback to add) + // Check if client with this email already exists to avoid duplicates if adding + if (!editingClient && updatedClients.some(c => c.email === submittedClientData.email)) { + setModalError(`Client with email ${submittedClientData.email} already exists.`); + setModalLoading(false); + return; + } + updatedClients.push(submittedClientData); + } + + const updatedSettingsJson = JSON.stringify({ ...currentSettings, clients: updatedClients }, null, 2); + const payloadForApi: Partial = { ...inbound, id: inbound.id, settings: updatedSettingsJson }; + + // Using a single update endpoint for simplicity + const response = await post(`/inbound/update/${inbound.id}`, payloadForApi); + + if (response.success) { + setIsModalOpen(false); setEditingClient(null); + await fetchInboundAndClients(); + } else { setModalError(response.message || `Failed to ${editingClient ? 'update' : 'add'} client.`); } + } catch (err) { setModalError(err instanceof Error ? err.message : `An error occurred.`); } + finally { setModalLoading(false); } + }; + + const handleDeleteClient = async (clientToDelete: DisplayClient) => { + if (!inbound) { setActionError("Inbound data not available for delete operation."); return; } + + // For deletion, we need the client's main identifier (email or id/password based on how backend delClient works) + // The backend /inbound/:id/delClient/:clientId expects :clientId to be the user's email. + // This was based on existing panel's behavior (delUser function in controllers). + const clientIdentifierForApi = clientToDelete.email; + + if (!clientIdentifierForApi) { + setActionError("Client email (identifier for API) is missing."); + return; + } + + if (!window.confirm(`Are you sure you want to delete client: ${clientToDelete.email}? This action might also remove associated traffic stats.`)) return; + + setActionClientId(clientIdentifierForApi); // Use email or other unique ID for loading state + setActionError(null); + try { + // API endpoint: /inbound/:id/delClient/:email + const response = await post(`/inbound/${inbound.id}/delClient/${clientIdentifierForApi}`, {}); + if (response.success) { + await fetchInboundAndClients(); // Refresh list + } else { + setActionError(response.message || "Failed to delete client."); + } + } catch (err) { + setActionError(err instanceof Error ? err.message : "An error occurred while deleting client."); + } finally { + setActionClientId(null); + } + }; + + const getClientIdentifier = (client: DisplayClient, proto: Protocol | undefined): string => proto === 'trojan' ? client.password || 'N/A' : client.id || 'N/A'; + const getClientIdentifierLabel = (proto: Protocol | undefined): string => proto === 'trojan' ? 'Password' : 'UUID'; + + if (isLoading || authLoading) return
Loading client data...
; + if (pageError && !inbound) return
Error: {pageError}
; + if (!inbound && !isLoading) return
Inbound data not available or not found.
; + + const canManageClients = inbound && (inbound.protocol === 'vmess' || inbound.protocol === 'vless' || inbound.protocol === 'trojan'); + + return ( +
+
+

+ Clients for: {inbound?.remark || `#${inbound?.id}`} + ({inbound?.protocol}) +

+ {canManageClients && ( + + )} +
+ + {pageError && inbound &&
Page load error: {pageError} (displaying potentially stale data)
} + {actionError &&
Action Error: {actionError}
} + + + {displayClients.length === 0 && !pageError &&

No clients configured for this inbound.

} + + {displayClients.length > 0 && ( +
+ + + + + {canManageClients && } + + + + + + + + + {displayClients.map((client) => { + const clientActionId = client.email; // Using email as the unique ID for delete action tracking + return ( + + + {canManageClients && } + + + + + + + )})} + +
Email{getClientIdentifierLabel(inbound?.protocol)}Traffic (Up/Down)QuotaExpiryStatusActions
{client.email}{getClientIdentifier(client, inbound?.protocol)}{formatBytes(client.up || 0)} / {formatBytes(client.down || 0)} + { (client.totalGB !== undefined && client.totalGB > 0) ? formatBytes(client.totalGB * 1024 * 1024 * 1024) : (client.actualTotal !== undefined && client.actualTotal > 0) ? formatBytes(client.actualTotal) : 'Unlimited' } + + {client.actualExpiryTime && client.actualExpiryTime > 0 ? new Date(client.actualExpiryTime).toLocaleDateString() : client.expiryTime && client.expiryTime > 0 ? new Date(client.expiryTime).toLocaleDateString() + " (Def)" : 'Never'} + + {client.enableClientStat === undefined ? 'N/A' : client.enableClientStat ? + Enabled : + Disabled + } + + {canManageClients && } + {canManageClients && + + } + + {canManageClients && (client.id || client.password) && } +
+
+ )} + {isModalOpen && inbound && ( + { setIsModalOpen(false); setEditingClient(null); }} + onSubmit={handleClientFormSubmit} + protocol={inbound.protocol as Protocol} + existingClient={editingClient} + formError={modalError} + isLoading={modalLoading} + /> + )} +
+ ); +}; +export default ManageClientsPage; diff --git a/new-frontend/src/app/inbounds/add/page.tsx b/new-frontend/src/app/inbounds/add/page.tsx new file mode 100644 index 00000000..5a96217d --- /dev/null +++ b/new-frontend/src/app/inbounds/add/page.tsx @@ -0,0 +1,42 @@ +"use client"; +import React, { useState } from 'react'; +import InboundForm from '@/components/inbounds/InboundForm'; +import { Inbound } from '@/types/inbound'; +import { post } from '@/services/api'; +import { useRouter } from 'next/navigation'; + +const AddInboundPage: React.FC = () => { + const [formLoading, setFormLoading] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + const handleSubmit = async (inboundData: Partial) => { + setFormLoading(true); + setError(null); + try { + const response = await post('/inbound/add', inboundData); + if (response.success && response.data) { + router.push('/inbounds'); + } else { + setError(response.message || 'Failed to create inbound.'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred.'); + } finally { + setFormLoading(false); + } + }; + + return ( +
+

Add New Inbound

+ {error && ( +
+ {error} +
+ )} + +
+ ); +}; +export default AddInboundPage; diff --git a/new-frontend/src/app/inbounds/edit/[id]/page.tsx b/new-frontend/src/app/inbounds/edit/[id]/page.tsx new file mode 100644 index 00000000..0face4f7 --- /dev/null +++ b/new-frontend/src/app/inbounds/edit/[id]/page.tsx @@ -0,0 +1,97 @@ +"use client"; +import React, { useState, useEffect, useCallback } from 'react'; +import InboundForm from '@/components/inbounds/InboundForm'; +import { Inbound } from '@/types/inbound'; +import { post } from '@/services/api'; +import { useRouter, useParams } from 'next/navigation'; + +const EditInboundPage: React.FC = () => { + const [pageLoading, setPageLoading] = useState(true); + const [formProcessing, setFormProcessing] = useState(false); + const [error, setError] = useState(null); + const [initialInboundData, setInitialInboundData] = useState(undefined); + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const fetchInboundData = useCallback(async () => { + if (!id) { + setError("Inbound ID is missing."); + setPageLoading(false); + return; + } + setPageLoading(true); + setError(null); + try { + // The /inbound/list endpoint returns all inbounds. + // We then find the specific one by ID. + // This is not ideal for a single item fetch but matches current backend capabilities shown. + // A dedicated /inbound/get/{id} would be better. + const response = await post('/inbound/list', {}); + if (response.success && response.data) { + const numericId = parseInt(id, 10); + const inbound = response.data.find(ib => ib.id === numericId); + if (inbound) { + setInitialInboundData(inbound); + } else { + setError('Inbound not found.'); + } + } else { + setError(response.message || 'Failed to fetch inbound data.'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred while fetching data.'); + } finally { + setPageLoading(false); + } + }, [id]); + + useEffect(() => { + fetchInboundData(); + }, [fetchInboundData]); + + const handleSubmit = async (inboundData: Partial) => { + setFormProcessing(true); + setError(null); + try { + const response = await post(`/inbound/update/${id}`, inboundData); + if (response.success) { + router.push('/inbounds'); + } else { + setError(response.message || 'Failed to update inbound.'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred.'); + } finally { + setFormProcessing(false); + } + }; + + if (pageLoading) { + return
Loading inbound data...
; + } + if (error && !initialInboundData) { + return
Error: {error}
; + } + if (!initialInboundData && !pageLoading) { + return
Inbound not found.
; + } + + return ( +
+

Edit Inbound: {initialInboundData?.remark || id}

+ {error && initialInboundData && ( +
+ {error} {/* Show non-critical errors above the form if data is loaded */} +
+ )} + {initialInboundData && } +
+ ); +}; +export default EditInboundPage; diff --git a/new-frontend/src/app/inbounds/page.tsx b/new-frontend/src/app/inbounds/page.tsx new file mode 100644 index 00000000..17660238 --- /dev/null +++ b/new-frontend/src/app/inbounds/page.tsx @@ -0,0 +1,194 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { post } from '@/services/api'; // ApiResponse removed +import { InboundFromList } from '@/types/inbound'; // Import the new types +import { formatBytes } from '@/lib/formatters'; // formatUptime removed +import Link from 'next/link'; // For "Add New Inbound" button + +// Simple toggle switch component (can be moved to ui components later) +const ToggleSwitch: React.FC<{ enabled: boolean; onChange: (enabled: boolean) => void; disabled?: boolean }> = ({ enabled, onChange, disabled }) => { + return ( + + ); +}; + + +const InboundsPage: React.FC = () => { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const [inbounds, setInbounds] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Action loading states + const [deletingId, setDeletingId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + + const fetchInbounds = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoading(true); + try { + const response = await post('/inbound/list', {}); + if (response.success && response.data) { + setInbounds(response.data); + setError(null); + } else { + setError(response.message || 'Failed to fetch inbounds.'); + setInbounds([]); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred.'); + setInbounds([]); + } finally { + setIsLoading(false); + } + }, [isAuthenticated]); + + useEffect(() => { + if (!authLoading && isAuthenticated) { + fetchInbounds(); + } else if (!authLoading && !isAuthenticated) { + setIsLoading(false); + setInbounds([]); + } + }, [isAuthenticated, authLoading, fetchInbounds]); + + const handleToggleEnable = async (inboundId: number, currentEnableStatus: boolean) => { + setTogglingId(inboundId); + // Find the inbound to get all its data for the update + const inboundToUpdate = inbounds.find(ib => ib.id === inboundId); + if (!inboundToUpdate) { + console.error("Inbound not found for toggling"); + setTogglingId(null); + return; + } + + // Create a payload that matches the expected structure for update, + // only changing the 'enable' field. + const payload = { ...inboundToUpdate, enable: !currentEnableStatus }; + + try { + const response = await post(`/inbound/update/${inboundId}`, payload); + if (response.success) { + await fetchInbounds(); // Refresh list + } else { + setError(response.message || 'Failed to update inbound status.'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Error updating inbound.'); + } finally { + setTogglingId(null); + } + }; + + const handleDeleteInbound = async (inboundId: number) => { + if (!confirm('Are you sure you want to delete this inbound? This action cannot be undone.')) { + return; + } + setDeletingId(inboundId); + try { + const response = await post(`/inbound/del/${inboundId}`, {}); + if (response.success) { + await fetchInbounds(); // Refresh list + } else { + setError(response.message || 'Failed to delete inbound.'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Error deleting inbound.'); + } finally { + setDeletingId(null); + } + }; + + + if (authLoading || isLoading) { + return
Loading inbounds...
; + } + + // If not authenticated and not loading auth, show message (AuthContext should redirect anyway) + if (!isAuthenticated && !authLoading) { + return
Please login to view inbounds.
; + } + + + return ( +
+
+

Inbounds Management

+ + Add New Inbound + +
+ + {error &&
Error: {error}
} + + {inbounds.length === 0 && !error &&

No inbounds found.

} + + {inbounds.length > 0 && ( +
+ + + + + + + + + + + + + + + {inbounds.map((inbound) => ( + + + + + + + + + + + ))} + +
RemarkProtocolPort / ListenTraffic (Up/Down)QuotaExpiryStatusActions
{inbound.remark || 'N/A'}{inbound.protocol}{inbound.port}{inbound.listen ? ` (${inbound.listen})` : ''}{formatBytes(inbound.up)} / {formatBytes(inbound.down)}{inbound.total > 0 ? formatBytes(inbound.total) : 'Unlimited'} + {inbound.expiryTime === 0 ? 'Never' : new Date(inbound.expiryTime).toLocaleDateString()} + + handleToggleEnable(inbound.id, inbound.enable)} + disabled={togglingId === inbound.id} + /> + + Edit + + {/* Placeholder for Manage Clients/Details */} + Clients +
+
+ )} +
+ ); +}; + +export default InboundsPage; diff --git a/new-frontend/src/app/layout.tsx b/new-frontend/src/app/layout.tsx new file mode 100644 index 00000000..6bfeb8d8 --- /dev/null +++ b/new-frontend/src/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import MainLayout from '@/components/layout/MainLayout'; +import { AuthProvider } from '@/context/AuthContext'; // Import AuthProvider + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "3X-UI New Panel", + description: "A new panel for 3X-UI", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {/* Wrap with AuthProvider */} + {children} + + + + ); +} diff --git a/new-frontend/src/app/page.tsx b/new-frontend/src/app/page.tsx new file mode 100644 index 00000000..4d8f249f --- /dev/null +++ b/new-frontend/src/app/page.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link'; + +export default function HomePage() { + return ( +
+

Welcome to the New 3X-UI Panel

+

Experience a fresh new look and feel.

+ + Go to Dashboard + +
+ ); +} diff --git a/new-frontend/src/app/settings/.gitkeep b/new-frontend/src/app/settings/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/settings/page.tsx b/new-frontend/src/app/settings/page.tsx new file mode 100644 index 00000000..f4dc9654 --- /dev/null +++ b/new-frontend/src/app/settings/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { post } from '@/services/api'; +import { AllSetting, UpdateUserPayload } from '@/types/settings'; +import PanelSettingsForm from '@/components/settings/PanelSettingsForm'; +import UserAccountSettingsForm from '@/components/settings/UserAccountSettingsForm'; +import TelegramSettingsForm from '@/components/settings/TelegramSettingsForm'; +import SubscriptionSettingsForm from '@/components/settings/SubscriptionSettingsForm'; +import OtherSettingsForm from '@/components/settings/OtherSettingsForm'; // Import the new form + +// Define button styles locally +const btnSecondaryStyles = "px-4 py-2 bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"; + +interface TabProps { label: string; isActive: boolean; onClick: () => void; } +const Tab: React.FC = ({ label, isActive, onClick }) => ( + +); + +const SettingsPage: React.FC = () => { + const { user: authUser, checkAuthState, isAuthenticated, isLoading: authContextLoading } = useAuth(); + const [settings, setSettings] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isUpdatingUser, setIsUpdatingUser] = useState(false); + + const [pageError, setPageError] = useState(null); + const [formError, setFormError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [activeTab, setActiveTab] = useState('panel'); + + const fetchSettings = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoading(true); setPageError(null); setFormError(null); + try { + const response = await post('/setting/all', {}); + if (response.success && response.data) { + setSettings(response.data); + } else { setPageError(response.message || 'Failed to fetch settings.'); } + } catch (err) { setPageError(err instanceof Error ? err.message : 'Unknown error fetching settings.'); } + finally { setIsLoading(false); } + }, [isAuthenticated]); + + useEffect(() => { + if (!authContextLoading && isAuthenticated) fetchSettings(); + else if (!authContextLoading && !isAuthenticated) setIsLoading(false); + }, [isAuthenticated, authContextLoading, fetchSettings]); + + const handleSaveSettings = async (updatedSettingsData: Partial) => { + setIsSaving(true); setFormError(null); setSuccessMessage(null); + let response; + try { + const fullSettingsPayload = { ...settings, ...updatedSettingsData }; + response = await post('/setting/update', fullSettingsPayload); + if (response.success) { + setSuccessMessage(response.message || 'Settings updated successfully! Some changes may require a panel restart.'); + await fetchSettings(); + } else { setFormError(response.message || 'Failed to update settings.'); } + } catch (err) { setFormError(err instanceof Error ? err.message : 'Unknown error saving settings.'); } + finally { + setIsSaving(false); + if (response?.success) { + setTimeout(()=>setSuccessMessage(null), 5000); + } else { + setTimeout(()=>setFormError(null), 6000); + } + } + }; + + const handleUpdateUserCredentials = async (payload: UpdateUserPayload): Promise => { + setIsUpdatingUser(true); setFormError(null); setSuccessMessage(null); + const finalPayload = { ...payload, oldUsername: payload.oldUsername || authUser?.username }; + let response; + try { + response = await post('/setting/updateUser', finalPayload); + if (response.success) { + setSuccessMessage(response.message || 'User credentials updated. You might need to log in again.'); + await checkAuthState(); + return true; + } else { setFormError(response.message || 'Failed to update user credentials.'); return false; } + } catch (err) { setFormError(err instanceof Error ? err.message : 'Unknown error updating user.'); return false; } + finally { setIsUpdatingUser(false); if (response?.success) setTimeout(()=>setSuccessMessage(null), 5000); if (formError) setTimeout(()=>setFormError(null), 6000);} + }; + + const handleUpdateTwoFactor = async (twoFactorEnabled: boolean) : Promise => { + setIsSaving(true); setFormError(null); setSuccessMessage(null); + let opSuccess = false; + try { + await handleSaveSettings({ twoFactorEnable: twoFactorEnabled }); + if (formError) { // Check if handleSaveSettings set an error + opSuccess = false; + } else { + const refreshed = await post('/setting/all', {}); + if(refreshed.success && refreshed.data){ + setSettings(refreshed.data); + if(refreshed.data.twoFactorEnable === twoFactorEnabled){ + setSuccessMessage(`2FA status successfully ${twoFactorEnabled ? 'enabled' : 'disabled'}.`); + opSuccess = true; + } else { + setFormError("2FA status change was not reflected after save. Please check."); + } + } else { + setFormError("Failed to re-fetch settings after 2FA update."); + } + } + } catch (err) { + setFormError(err instanceof Error ? err.message : "Error in 2FA update process."); + } finally { + setIsSaving(false); + if (opSuccess) setTimeout(()=>setSuccessMessage(null), 5000); + else if (formError) setTimeout(()=>setFormError(null), 8000); + } + return opSuccess; + }; + + const handleRestartPanel = async () => { + if (!window.confirm("Are you sure you want to restart the panel?")) return; + setIsSaving(true); setFormError(null); setSuccessMessage(null); + try { + const response = await post('/setting/restartPanel', {}); + if (response.success) { + setSuccessMessage(response.message || "Panel is restarting... Please wait and refresh."); + } else { setFormError(response.message || "Failed to restart panel."); } + } catch (err) { setFormError(err instanceof Error ? err.message : "Error restarting panel."); } + finally { setIsSaving(false); if (successMessage) setTimeout(()=>setSuccessMessage(null), 7000); if (formError) setTimeout(()=>setFormError(null), 6000); } + }; + + const onTabChange = (tab: string) => { + setActiveTab(tab); setFormError(null); setSuccessMessage(null); + }; + + const renderSettingsContent = () => { + if (isLoading && !Object.keys(settings).length) return

Loading settings data...

; + + switch(activeTab) { + case 'panel': + return ; + case 'user': + return ; + case 'telegram': + return ; + case 'subscription': + return ; + case 'other': + return ; + default: + return

Please select a settings category.

; + } + }; + + if (authContextLoading) return
Loading authentication...
; + if (pageError && !Object.keys(settings).length) return
Error: {pageError}
; + + return ( +
+

Panel Settings

+ + {successMessage &&
{successMessage}
} + {pageError && !isLoading &&
Page Error: {pageError}
} + +
+ +
+ +
+ {renderSettingsContent()} +
+ +
+ +
+
+ ); +}; + +export default SettingsPage; diff --git a/new-frontend/src/components/dashboard/StatCard.tsx b/new-frontend/src/components/dashboard/StatCard.tsx new file mode 100644 index 00000000..31cbf735 --- /dev/null +++ b/new-frontend/src/components/dashboard/StatCard.tsx @@ -0,0 +1,21 @@ +import React, { ReactNode } from 'react'; + +interface StatCardProps { + title: string; + children: ReactNode; + className?: string; + actions?: ReactNode; +} + +const StatCard: React.FC = ({ title, children, className = '', actions }) => { + return ( +
+

{title}

+
+ {children} +
+ {actions &&
{actions}
} +
+ ); +}; +export default StatCard; diff --git a/new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx b/new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx new file mode 100644 index 00000000..3a8662e4 --- /dev/null +++ b/new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx @@ -0,0 +1,191 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from 'react'; +import { post } from '@/services/api'; + +// Define styles locally +const btnSecondaryStyles = "px-3 py-1 text-xs bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"; + +interface XrayGeoManagementModalProps { + isOpen: boolean; + onClose: () => void; + currentXrayVersion: string | undefined; + onActionComplete: () => Promise; // Callback to refresh dashboard status +} + +const GEO_FILES_LIST = [ + "geoip.dat", + "geosite.dat", + "geoip_IR.dat", + "geosite_IR.dat", +]; + +const XrayGeoManagementModal: React.FC = ({ + isOpen, onClose, currentXrayVersion, onActionComplete +}) => { + const [xrayVersions, setXrayVersions] = useState([]); + const [isLoadingVersions, setIsLoadingVersions] = useState(false); + const [isInstalling, setIsInstalling] = useState(null); + const [isUpdatingFile, setIsUpdatingFile] = useState(null); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const [activeTab, setActiveTab] = useState<'xray' | 'geo'>('xray'); + + const fetchXrayVersions = useCallback(async () => { + if (!isOpen) return; // Ensure modal is open before fetching + setIsLoadingVersions(true); setError(null); + try { + // Explicitly type the expected response structure + const response = await post<{ versions?: string[], data?: string[] }>('/server/getXrayVersion', {}); + if (response.success) { + const versionsData = response.data; // data might be string[] or { versions: string[] } + if (Array.isArray(versionsData)) { // Case: data is string[] + setXrayVersions(versionsData); + } else if (versionsData && Array.isArray(versionsData.versions)) { // Case: data is { versions: string[] } + setXrayVersions(versionsData.versions); + } else { + setError('Fetched Xray versions data is not in the expected format.'); + setXrayVersions([]); + } + } else { + setError(response.message || 'Failed to fetch Xray versions.'); + setXrayVersions([]); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error fetching versions.'); + setXrayVersions([]); + } finally { + setIsLoadingVersions(false); + } + }, [isOpen]); + + useEffect(() => { + if (isOpen && activeTab === 'xray') { + fetchXrayVersions(); + } + }, [isOpen, activeTab, fetchXrayVersions]); + + const handleInstallXray = async (version: string) => { + if (!window.confirm(`Are you sure you want to install Xray version: ${version}?\nThis will restart Xray service.`)) return; + setIsInstalling(version); setError(null); setSuccessMessage(null); + let response; // Declare response here + try { + response = await post(`/server/installXray/${version}`, {}); + if (response.success) { + setSuccessMessage(response.message || `Xray ${version} installed successfully. Xray service is restarting.`); + onActionComplete(); + } else { + setError(response.message || 'Failed to install Xray version.'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error during Xray installation.'); + } finally { + setIsInstalling(null); + if (response?.success) setTimeout(() => setSuccessMessage(null), 4000); + else if (error) setTimeout(() => setError(null), 4000); // Check local error state + } + }; + + const handleUpdateGeoFile = async (fileName: string) => { + if (!window.confirm(`Are you sure you want to update ${fileName}?\nThis may restart Xray service if changes are detected.`)) return; + setIsUpdatingFile(fileName); setError(null); setSuccessMessage(null); + let response; // Declare response here + try { + response = await post(`/server/updateGeofile/${fileName}`, {}); + if (response.success) { + setSuccessMessage(response.message || `${fileName} updated successfully.`); + onActionComplete(); + } else { + setError(response.message || `Failed to update ${fileName}.`); + } + } catch (err) { + setError(err instanceof Error ? err.message : `Unknown error updating ${fileName}.`); + } finally { + setIsUpdatingFile(null); + if (response?.success) setTimeout(() => setSuccessMessage(null), 4000); + else if (error) setTimeout(() => setError(null), 4000); // Check local error state + } + }; + + const handleClose = () => { + setError(null); + setSuccessMessage(null); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

Xray & Geo Management

+ +
+ + {error &&
{error}
} + {successMessage &&
{successMessage}
} + +
+ +
+ +
+ {activeTab === 'xray' && ( +
+

Current Xray Version: {currentXrayVersion || 'Unknown'}

+ {isLoadingVersions &&

Loading versions...

} + {!isLoadingVersions && xrayVersions.length === 0 && !error &&

No versions found or failed to load.

} +
    + {xrayVersions.map(version => ( +
  • + {version} + {version === currentXrayVersion ? ( + Current + ) : ( + + )} +
  • + ))} +
+
+ )} + + {activeTab === 'geo' && ( +
+

Manage GeoIP and GeoSite files.

+
    + {GEO_FILES_LIST.map(fileName => ( +
  • + {fileName} + +
  • + ))} +
+
+ )} +
+
+ +
+
+
+ ); +}; +export default XrayGeoManagementModal; diff --git a/new-frontend/src/components/dashboard/XrayStatusIndicator.tsx b/new-frontend/src/components/dashboard/XrayStatusIndicator.tsx new file mode 100644 index 00000000..a9b08d38 --- /dev/null +++ b/new-frontend/src/components/dashboard/XrayStatusIndicator.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +type ProcessState = "running" | "stop" | "error" | undefined; + +interface XrayStatusIndicatorProps { + state?: ProcessState; + version?: string; + errorMsg?: string; +} + +const XrayStatusIndicator: React.FC = ({ state, version, errorMsg }) => { + let color = 'bg-gray-400'; // Default for undefined or unknown state + let text = 'Unknown'; + + switch (state) { + case 'running': + color = 'bg-green-500'; + text = 'Running'; + break; + case 'stop': + color = 'bg-yellow-500'; + text = 'Stopped'; + break; + case 'error': + color = 'bg-red-500'; + text = 'Error'; + break; + } + + return ( +
+
+ + {text} +
+ {version &&

Version: {version}

} + {state === 'error' && errorMsg && ( +

+ Error: {errorMsg.length > 100 ? errorMsg.substring(0, 97) + "..." : errorMsg} +

+ )} +
+ ); +}; +export default XrayStatusIndicator; diff --git a/new-frontend/src/components/inbounds/ClientFormModal.tsx b/new-frontend/src/components/inbounds/ClientFormModal.tsx new file mode 100644 index 00000000..9ec2f182 --- /dev/null +++ b/new-frontend/src/components/inbounds/ClientFormModal.tsx @@ -0,0 +1,138 @@ +"use client"; + +import React, { useState, useEffect, FormEvent } from 'react'; +import { ClientSetting, Protocol } from '@/types/inbound'; + +// Basic UUID v4 generator +const generateUUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); +}); +const generateRandomPassword = (length = 12) => Math.random().toString(36).substring(2, 2 + length); + +const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"; +const btnPrimaryStyles = "px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors"; +const btnSecondaryStyles = "px-4 py-2 bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"; + + +interface ClientFormModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (client: ClientSetting) => void; + protocol: Protocol; + existingClient?: ClientSetting | null; + formError?: string | null; + isLoading?: boolean; +} + +const ClientFormModal: React.FC = ({ + isOpen, onClose, onSubmit, protocol, existingClient = null, formError, isLoading +}) => { + const isEditMode = !!existingClient; + const [email, setEmail] = useState(''); + const [identifier, setIdentifier] = useState(''); + const [flow, setFlow] = useState(''); + const [totalGB, setTotalGB] = useState(0); + const [expiryTime, setExpiryTime] = useState(0); + const [limitIp, setLimitIp] = useState(0); + + useEffect(() => { + if (isOpen) { // Only reset/populate when modal becomes visible or critical props change + if (existingClient) { + setEmail(existingClient.email || ''); + if (protocol === 'trojan') { + setIdentifier(existingClient.password || ''); + } else { // vmess, vless + setIdentifier(existingClient.id || ''); + } + setFlow(existingClient.flow || ''); + setTotalGB(existingClient.totalGB === undefined ? 0 : existingClient.totalGB); + setExpiryTime(existingClient.expiryTime === undefined ? 0 : existingClient.expiryTime); + setLimitIp(existingClient.limitIp === undefined ? 0 : existingClient.limitIp); + } else { + setEmail(''); + setIdentifier(protocol === 'trojan' ? generateRandomPassword() : generateUUID()); + setFlow(protocol === 'vless' ? 'xtls-rprx-vision' : ''); + setTotalGB(0); + setExpiryTime(0); + setLimitIp(0); + } + } + }, [isOpen, existingClient, protocol]); + + if (!isOpen) return null; + + const identifierLabel = protocol === 'trojan' ? 'Password' : 'UUID'; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const clientData: ClientSetting = { + email, + flow: protocol === 'vless' ? flow : undefined, + totalGB: Number(totalGB), + expiryTime: Number(expiryTime), + limitIp: Number(limitIp), + }; + if (protocol === 'trojan') { + clientData.password = identifier; + } else { // vmess, vless + clientData.id = identifier; + } + // If editing, preserve original ID if it was a UUID that shouldn't change + if (isEditMode && existingClient?.id && protocol !== 'trojan') { + clientData.id = existingClient.id; + } + onSubmit(clientData); + }; + + return ( +
+
+

+ {isEditMode ? 'Edit Client' : 'Add New Client'} +

+ {formError &&

{formError}

} +
+
+ + setEmail(e.target.value)} required className={`mt-1 w-full ${inputStyles}`} /> +
+
+ +
+ setIdentifier(e.target.value)} required className={`mt-1 w-full rounded-r-none ${inputStyles}`} /> + +
+
+ {protocol === 'vless' && ( +
+ + setFlow(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="e.g., xtls-rprx-vision" /> +
+ )} +
+ + setTotalGB(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} /> +
+
+ + setExpiryTime(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} /> +
+
+ + setLimitIp(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} /> +
+
+ + +
+
+
+
+ ); +}; +export default ClientFormModal; diff --git a/new-frontend/src/components/inbounds/ClientShareModal.tsx b/new-frontend/src/components/inbounds/ClientShareModal.tsx new file mode 100644 index 00000000..243b3d8b --- /dev/null +++ b/new-frontend/src/components/inbounds/ClientShareModal.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; +import { Inbound, ClientSetting } from '@/types/inbound'; +import { generateSubscriptionLink } from '@/lib/subscriptionLink'; +import { QRCodeCanvas } from 'qrcode.react'; + +const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 select-all"; +const btnSecondaryStyles = "px-4 py-2 bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors text-sm"; + + +interface ClientShareModalProps { + isOpen: boolean; + onClose: () => void; + inbound: Inbound | null; + client: ClientSetting | null; // For client-specific links (vmess, vless, trojan) + // For shadowsocks, client info is mostly in inbound.settings +} + +const ClientShareModal: React.FC = ({ isOpen, onClose, inbound, client }) => { + const [link, setLink] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (isOpen && inbound) { + // For Shadowsocks, the 'client' might be a conceptual representation derived from inbound settings, + // or we can pass a minimal ClientSetting object. + // The generateSubscriptionLink function expects a ClientSetting object. + // If protocol is shadowsocks and client prop is null, we might need to construct one. + let effectiveClient = client; + if (inbound.protocol === 'shadowsocks' && !client) { + // Construct a conceptual client for shadowsocks if client prop is null + // The link generator for SS primarily uses inbound.settings anyway. + effectiveClient = { email: inbound.remark || 'shadowsocks_client' }; + } + + if (effectiveClient) { + setLink(generateSubscriptionLink(inbound, effectiveClient)); + } else { + setLink(null); + } + setCopied(false); + } + }, [isOpen, inbound, client]); + + if (!isOpen || !inbound) return null; + // If client is null for protocols that require it, link generation will fail. + // This is handled by generateSubscriptionLink returning null. + + const handleCopy = () => { + if (link) { + navigator.clipboard.writeText(link) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(err => console.error('Failed to copy link: ', err)); + } + }; + + return ( +
+
e.stopPropagation()} // Prevent modal close when clicking inside modal + > +
+

Share Client Configuration

+ +
+ + {link ? ( +
+
+ +
+
+ +
+ + +
+
+

+ Note: The server address used ("YOUR_SERVER_IP_OR_DOMAIN") is a placeholder. + For this link to work, ensure your actual server address/domain is correctly configured in the generation logic + (src/lib/subscriptionLink.ts) or, ideally, fetched from panel settings in a future update. +

+
+ ) : ( +

Could not generate subscription link for this client/protocol combination.

+ )} + +
+ +
+
+
+ ); +}; +export default ClientShareModal; diff --git a/new-frontend/src/components/inbounds/InboundForm.tsx b/new-frontend/src/components/inbounds/InboundForm.tsx new file mode 100644 index 00000000..d7678a0a --- /dev/null +++ b/new-frontend/src/components/inbounds/InboundForm.tsx @@ -0,0 +1,239 @@ +"use client"; + +import React, { useState, useEffect, FormEvent } from 'react'; +import { Inbound, Protocol, ClientSetting } from '@/types/inbound'; +import { useRouter } from 'next/navigation'; +import ProtocolClientSettings from './ProtocolClientSettings'; +import StreamSettingsForm from './stream_settings/StreamSettingsForm'; // Import new StreamSettingsForm + +const availableProtocols: Protocol[] = ["vmess", "vless", "trojan", "shadowsocks", "dokodemo-door", "socks", "http"]; +const availableShadowsocksCiphers: string[] = [ + "aes-256-gcm", "aes-128-gcm", "chacha20-poly1305", "xchacha20-poly1305", + "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "none" +]; +const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"; + +interface InboundFormProps { + initialData?: Partial; + isEditMode?: boolean; + formLoading?: boolean; + onSubmitForm: (inboundData: Partial) => Promise; +} + +const InboundForm: React.FC = ({ initialData, isEditMode = false, formLoading, onSubmitForm }) => { + const router = useRouter(); + const [remark, setRemark] = useState(''); + const [listen, setListen] = useState(''); + const [port, setPort] = useState(''); + const [protocol, setProtocol] = useState(''); + const [enable, setEnable] = useState(true); + const [expiryTime, setExpiryTime] = useState(0); + const [total, setTotal] = useState(0); + + const [clientList, setClientList] = useState([]); + const [ssMethod, setSsMethod] = useState(availableShadowsocksCiphers[0]); + const [ssPassword, setSsPassword] = useState(''); + + const [settingsJson, setSettingsJson] = useState('{}'); + const [streamSettingsJson, setStreamSettingsJson] = useState('{}'); + const [sniffingJson, setSniffingJson] = useState('{}'); + + const [formError, setFormError] = useState(null); + + useEffect(() => { + if (initialData) { + const currentProtocol = (initialData.protocol || '') as Protocol; + setRemark(initialData.remark || ''); + setListen(initialData.listen || ''); + setPort(initialData.port || ''); + setProtocol(currentProtocol); + setEnable(initialData.enable !== undefined ? initialData.enable : true); + setExpiryTime(initialData.expiryTime || 0); + setTotal(initialData.total && initialData.total > 0 ? initialData.total / (1024 * 1024 * 1024) : 0); + + setStreamSettingsJson(initialData.streamSettings || '{}'); + setSniffingJson(initialData.sniffing || '{}'); + + const initialSettings = initialData.settings || '{}'; + setSettingsJson(initialSettings); // Initialize settingsJson with all settings + + if ((currentProtocol === 'vmess' || currentProtocol === 'vless' || currentProtocol === 'trojan')) { + try { + const parsedSettings = JSON.parse(initialSettings); + setClientList(parsedSettings.clients || []); + } catch (e) { console.error(`Error parsing settings for ${currentProtocol}:`, e); setClientList([]); } + } else if (currentProtocol === 'shadowsocks') { + try { + const parsedSettings = JSON.parse(initialSettings); + setSsMethod(parsedSettings.method || availableShadowsocksCiphers[0]); + setSsPassword(parsedSettings.password || ''); + } catch (e) { console.error("Error parsing Shadowsocks settings:", e); } + } else { + // For other protocols, specific UI states are not used for settings + setClientList([]); + setSsMethod(availableShadowsocksCiphers[0]); setSsPassword(''); + } + } else { + // Reset all fields for a new form + setRemark(''); setListen(''); setPort(''); setProtocol(''); setEnable(true); + setExpiryTime(0); setTotal(0); setClientList([]); + setSsMethod(availableShadowsocksCiphers[0]); setSsPassword(''); + setSettingsJson('{}'); setStreamSettingsJson('{}'); setSniffingJson('{}'); + } + }, [initialData]); + + // This useEffect updates settingsJson based on UI changes for client lists or SS settings + useEffect(() => { + if (protocol === 'vmess' || protocol === 'vless' || protocol === 'trojan' || protocol === 'shadowsocks') { + let baseSettings: Record = {}; + try { + baseSettings = JSON.parse(settingsJson) || {}; // Preserve other settings + } catch { /* If settingsJson is invalid, it will be overwritten */ } + + const newSettingsObject = { ...baseSettings }; // Changed to const + + if (protocol === 'vmess' || protocol === 'vless' || protocol === 'trojan') { + delete newSettingsObject.method; delete newSettingsObject.password; + newSettingsObject.clients = clientList; + } else if (protocol === 'shadowsocks') { + delete newSettingsObject.clients; + newSettingsObject.method = ssMethod; + newSettingsObject.password = ssPassword; + } + + try { + const finalJson = JSON.stringify(newSettingsObject, null, 2); + if (finalJson !== settingsJson) { // Only update if there's an actual change + setSettingsJson(finalJson); + } + } catch (e) { console.error("Error stringifying settings:", e); } + } + // No 'else' needed: for other protocols, settingsJson is managed by its textarea directly. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientList, ssMethod, ssPassword, protocol]); // settingsJson is NOT a dependency here. + + + const handleProtocolChange = (newProtocol: Protocol) => { + setProtocol(newProtocol); + setFormError(null); + + // When protocol changes, re-initialize specific UI states from current settingsJson + let currentSettings: Record = {}; // Changed any to unknown + try { currentSettings = JSON.parse(settingsJson || '{}'); } catch {} + + if (newProtocol === 'vmess' || newProtocol === 'vless' || newProtocol === 'trojan') { + if (Array.isArray(currentSettings.clients)) { + setClientList(currentSettings.clients as ClientSetting[]); + } else { + setClientList([]); + } + setSsMethod(availableShadowsocksCiphers[0]); setSsPassword(''); + } else if (newProtocol === 'shadowsocks') { + setClientList([]); + if (typeof currentSettings.method === 'string') { + setSsMethod(currentSettings.method); + } else { + setSsMethod(availableShadowsocksCiphers[0]); + } + if (typeof currentSettings.password === 'string') { + setSsPassword(currentSettings.password); + } else { + setSsPassword(''); + } + } else { + // For "other" protocols, clear all specific UI states + setClientList([]); + setSsMethod(availableShadowsocksCiphers[0]); setSsPassword(''); + // settingsJson remains as is, for manual editing + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); setFormError(null); + if (!protocol) { setFormError("Protocol is required."); return; } + if (port === '' || Number(port) <= 0 || Number(port) > 65535) { setFormError("Valid Port (1-65535) is required."); return; } + + // settingsJson should already be up-to-date from the useEffect hook + // for vmess/vless/trojan/shadowsocks. + // For other protocols, it's taken directly from its textarea. + if (protocol === 'shadowsocks' && !ssPassword) { + setFormError("Password is required for Shadowsocks."); return; + } + + // Validate all JSON fields before submitting + for (const [fieldName, jsonStr] of Object.entries({ settings: settingsJson, streamSettings: streamSettingsJson, sniffing: sniffingJson })) { + try { JSON.parse(jsonStr); } catch (err) { + setFormError(`Invalid JSON in ${fieldName === 'settings' ? 'protocol settings' : fieldName}: ${(err as Error).message}`); return; + } + } + + const inboundData: Partial = { + remark, listen, port: Number(port), protocol, enable, + expiryTime: Number(expiryTime), total: Number(total) * 1024 * 1024 * 1024, + settings: settingsJson, streamSettings: streamSettingsJson, sniffing: sniffingJson, + }; + + if (isEditMode && initialData?.id) { + inboundData.id = initialData.id; + inboundData.up = initialData.up; inboundData.down = initialData.down; + } + await onSubmitForm(inboundData); + }; + + return ( +
+ {formError &&
{formError}
} + +
+ Basic Settings +
+
setRemark(e.target.value)} className={inputStyles} />
+
+
setListen(e.target.value)} placeholder="Default: 0.0.0.0" className={inputStyles} />
+
setPort(e.target.value === '' ? '' : Number(e.target.value))} required min="1" max="65535" className={inputStyles} />
+
setTotal(e.target.value === '' ? '' : Number(e.target.value))} min="0" placeholder="0 for unlimited" className={inputStyles} />
+
setExpiryTime(e.target.value === '' ? '' : Number(e.target.value))} min="0" placeholder="0 for never" className={inputStyles} />
+
setEnable(e.target.checked)} className="h-4 w-4 text-primary-600 border-gray-300 dark:border-gray-500 rounded focus:ring-primary-500 bg-white dark:bg-gray-700" />
+
+
+ + {protocol && ( +
+ {protocol} Settings + {(protocol === 'vmess' || protocol === 'vless' || protocol === 'trojan') ? ( + + ) : protocol === 'shadowsocks' ? ( +
+
+
setSsPassword(e.target.value)} required className={inputStyles} />
+
+ ) : ( +