diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 70ee22f0b..2b3179a19 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2744,6 +2744,9 @@ "mtprotoDomain": { "type": "string" }, + "network": { + "type": "string" + }, "nodeAddress": { "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", "type": "string" @@ -2765,6 +2768,9 @@ "example": "VLESS-443", "type": "string" }, + "security": { + "type": "string" + }, "shareAddr": { "type": "string" }, @@ -4515,11 +4521,13 @@ "id": 1, "listen": "", "mtprotoDomain": "", + "network": "", "nodeAddress": "", "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", + "security": "", "shareAddr": "", "shareAddrStrategy": "", "ssMethod": "", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 70ee22f0b..2b3179a19 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2744,6 +2744,9 @@ "mtprotoDomain": { "type": "string" }, + "network": { + "type": "string" + }, "nodeAddress": { "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", "type": "string" @@ -2765,6 +2768,9 @@ "example": "VLESS-443", "type": "string" }, + "security": { + "type": "string" + }, "shareAddr": { "type": "string" }, @@ -4515,11 +4521,13 @@ "id": 1, "listen": "", "mtprotoDomain": "", + "network": "", "nodeAddress": "", "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", + "security": "", "shareAddr": "", "shareAddrStrategy": "", "ssMethod": "", diff --git a/frontend/src/components/command-palette/CommandPalette.css b/frontend/src/components/command-palette/CommandPalette.css new file mode 100644 index 000000000..570b71a98 --- /dev/null +++ b/frontend/src/components/command-palette/CommandPalette.css @@ -0,0 +1,625 @@ +/* -------------------------------------------------------------------------- + Command Palette Backdrop & Modal + -------------------------------------------------------------------------- */ + +.command-palette-backdrop { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + animation: cp-fade-in 0.15s ease-out; +} + +.command-palette-backdrop.light { + background: rgba(0, 0, 0, 0.25); +} + +.command-palette-backdrop.ultra { + background: rgba(0, 0, 0, 0.75); +} + +@keyframes cp-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.command-palette-modal { + position: relative; + width: 680px; + max-width: 92vw; + max-height: 72vh; + display: flex; + flex-direction: column; + border-radius: 14px; + overflow: hidden; + animation: cp-scale-in 0.18s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease; +} + +@keyframes cp-scale-in { + from { + opacity: 0; + transform: scale(0.96) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.command-palette-modal.light, +body.light .command-palette-modal { + background: rgba(255, 255, 255, 0.96); + color: #1f1f1f; + border: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: + 0 20px 50px rgba(0, 0, 0, 0.16), + 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.command-palette-modal.dark, +body.dark .command-palette-modal { + background: rgba(35, 37, 43, 0.95); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: + 0 25px 60px rgba(0, 0, 0, 0.65), + 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.command-palette-modal.ultra, +html[data-theme='ultra-dark'] .command-palette-modal { + background: rgba(16, 16, 19, 0.98); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.9); +} + +/* -------------------------------------------------------------------------- + Header & Search Input + -------------------------------------------------------------------------- */ + +.command-palette-header { + display: flex; + align-items: center; + padding: 16px 20px; + gap: 14px; + border-bottom: 1px solid; +} + +.command-palette-modal.light .command-palette-header { + border-bottom-color: rgba(0, 0, 0, 0.08); +} + +.command-palette-modal.dark .command-palette-header { + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +.command-palette-modal.ultra .command-palette-header { + border-bottom-color: rgba(255, 255, 255, 0.12); +} + +.command-palette-search-icon { + font-size: 24px; + width: 28px; + height: 28px; + color: #1677ff; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; +} + +.command-palette-search-icon.spinning { + color: #1677ff; + animation: cp-spin 0.8s linear infinite; +} + +@keyframes cp-spin { + 100% { + transform: rotate(360deg); + } +} + +.command-palette-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 16.5px; + font-weight: 400; + font-family: inherit; + letter-spacing: -0.2px; +} + +.command-palette-modal.light .command-palette-input { + color: #1f1f1f; +} + +.command-palette-modal.light .command-palette-input::placeholder { + color: #8c8c8c; +} + +.command-palette-modal.dark .command-palette-input, +.command-palette-modal.ultra .command-palette-input { + color: #ffffff; +} + +.command-palette-modal.dark .command-palette-input::placeholder, +.command-palette-modal.ultra .command-palette-input::placeholder { + color: #737373; +} + +/* -------------------------------------------------------------------------- + Body & Scrollbar + -------------------------------------------------------------------------- */ + +.command-palette-body { + flex: 1; + overflow-y: auto; + padding: 10px; + max-height: calc(72vh - 116px); + scrollbar-width: thin; +} + +.command-palette-modal.light .command-palette-body { + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} + +.command-palette-modal.dark .command-palette-body, +.command-palette-modal.ultra .command-palette-body { + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +.command-palette-body::-webkit-scrollbar { + width: 6px; +} + +.command-palette-body::-webkit-scrollbar-track { + background: transparent; +} + +.command-palette-modal.light .command-palette-body::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.command-palette-modal.light .command-palette-body::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.35); +} + +.command-palette-modal.dark .command-palette-body::-webkit-scrollbar-thumb, +.command-palette-modal.ultra .command-palette-body::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.command-palette-modal.dark .command-palette-body::-webkit-scrollbar-thumb:hover, +.command-palette-modal.ultra .command-palette-body::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.35); +} + +/* -------------------------------------------------------------------------- + Groups & Items + -------------------------------------------------------------------------- */ + +.command-palette-group { + margin-bottom: 8px; +} + +.command-palette-group-title { + padding: 6px 12px 4px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.command-palette-modal.light .command-palette-group-title { + color: #8c8c8c; +} + +.command-palette-modal.dark .command-palette-group-title, +.command-palette-modal.ultra .command-palette-group-title { + color: #8c8c8c; +} + +.command-palette-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 9px 14px; + background: transparent; + border: none; + border-radius: 9px; + cursor: pointer; + text-align: start; + font-family: inherit; + /* The row was a + + )} + + + + ); + })} + + +
+
+ + + + {t('commandPalette.navigate')} + + + + {t('commandPalette.select')} + + + Esc + {t('close')} + +
+ 3x-ui Command Palette +
+ + + + ); +} diff --git a/frontend/src/components/command-palette/useCommandPalette.ts b/frontend/src/components/command-palette/useCommandPalette.ts new file mode 100644 index 000000000..18706175b --- /dev/null +++ b/frontend/src/components/command-palette/useCommandPalette.ts @@ -0,0 +1,49 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +let isOpen = false; +const listeners = new Set<() => void>(); + +function notify() { + listeners.forEach((listener) => listener()); +} + +export const commandPaletteStore = { + getSnapshot: () => isOpen, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + open: () => { + if (!isOpen) { + isOpen = true; + notify(); + } + }, + close: () => { + if (isOpen) { + isOpen = false; + notify(); + } + }, + toggle: () => { + isOpen = !isOpen; + notify(); + }, +}; + +export function useCommandPalette() { + const open = useSyncExternalStore(commandPaletteStore.subscribe, commandPaletteStore.getSnapshot); + + const openPalette = useCallback(() => commandPaletteStore.open(), []); + const closePalette = useCallback(() => commandPaletteStore.close(), []); + const togglePalette = useCallback(() => commandPaletteStore.toggle(), []); + + return { + isOpen: open, + open: openPalette, + close: closePalette, + toggle: togglePalette, + }; +} diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index f50c0651e..4e197e143 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -712,11 +712,13 @@ export const EXAMPLES: Record = { "id": 1, "listen": "", "mtprotoDomain": "", + "network": "", "nodeAddress": "", "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", + "security": "", "shareAddr": "", "shareAddrStrategy": "", "ssMethod": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index b4aee4923..dd2314aa7 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2718,6 +2718,9 @@ export const SCHEMAS: Record = { "mtprotoDomain": { "type": "string" }, + "network": { + "type": "string" + }, "nodeAddress": { "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", "type": "string" @@ -2739,6 +2742,9 @@ export const SCHEMAS: Record = { "example": "VLESS-443", "type": "string" }, + "security": { + "type": "string" + }, "shareAddr": { "type": "string" }, diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index a6dee4cc6..8cbb92f81 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -622,11 +622,13 @@ export interface InboundOption { id: number; listen?: string; mtprotoDomain?: string; + network?: string; nodeAddress?: string; nodeId?: number | null; port: number; protocol: string; remark: string; + security?: string; shareAddr?: string; shareAddrStrategy?: string; ssMethod: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index d7720c920..90c32b2ab 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -664,11 +664,13 @@ export const InboundOptionSchema = z.object({ id: z.number().int(), listen: z.string().optional(), mtprotoDomain: z.string().optional(), + network: z.string().optional(), nodeAddress: z.string().optional(), nodeId: z.number().int().nullable().optional(), port: z.number().int(), protocol: z.string(), remark: z.string(), + security: z.string().optional(), shareAddr: z.string().optional(), shareAddrStrategy: z.string().optional(), ssMethod: z.string(), diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index a0159a10b..f3c668c14 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentType, CSSProperties } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { Drawer, Layout, Menu } from 'antd'; +import { Drawer, Layout, Menu, Tooltip } from 'antd'; import type { MenuProps } from 'antd'; import { ApiOutlined, @@ -28,6 +28,7 @@ import { PushpinOutlined, ReadOutlined, SafetyOutlined, + SearchOutlined, SettingOutlined, SunOutlined, SwapOutlined, @@ -40,9 +41,13 @@ import { HttpUtil } from '@/utils'; import { formatPanelVersion } from '@/lib/panel-version'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { useAllSettings } from '@/api/queries/useAllSettings'; +import { useCommandPalette } from '@/components/command-palette/useCommandPalette'; import './AppSidebar.css'; const DONATE_URL = 'https://donate.sanaei.dev/'; +// The palette listens for Ctrl as well as Cmd, so the chip must not show a +// Mac glyph to the Linux and Windows operators who are most of this panel's. +const SHORTCUT_MODIFIER = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? '⌘' : 'Ctrl'; const DOCS_URL = 'https://docs.sanaei.dev/'; const REPO_URL = 'https://github.com/MHSanaei/3x-ui'; const LOGOUT_KEY = '__logout__'; @@ -174,6 +179,7 @@ function saveSidebarPinned(pinned: boolean) { export default function AppSidebar() { const { t } = useTranslation(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); + const { open: openCommandPalette } = useCommandPalette(); const navigate = useNavigate(); const { pathname, hash } = useLocation(); const { allSetting } = useAllSettings(); @@ -392,6 +398,30 @@ export default function AppSidebar() { )} + + + + ; + return ( + <> + + + + ); } diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 27429c91a..96a9e6952 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -1,4 +1,5 @@ import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Badge, @@ -382,7 +383,12 @@ export default function ClientsPage() { >(null); const initial = readFilterState(); - const [searchKey, setSearchKey] = useState(initial.searchKey); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const searchParam = searchParams.get('search'); + const [searchKey, setSearchKey] = useState( + searchParam !== null ? searchParam : initial.searchKey, + ); const [filters, setFilters] = useState(initial.filters); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); @@ -405,6 +411,15 @@ export default function ClientsPage() { // debouncedSearch lags behind the input so we don't spam the server on every // keystroke; the search box still feels instant locally. const [debouncedSearch, setDebouncedSearch] = useState(searchKey); + const [prevLocationKey, setPrevLocationKey] = useState(location.key); + + if (location.key !== prevLocationKey) { + setPrevLocationKey(location.key); + if (searchParam !== null) { + setSearchKey(searchParam); + setDebouncedSearch(searchParam); + } + } useEffect(() => { localStorage.setItem( diff --git a/frontend/src/pages/inbounds/list/InboundList.tsx b/frontend/src/pages/inbounds/list/InboundList.tsx index 0f34fc8dd..f9468ce52 100644 --- a/frontend/src/pages/inbounds/list/InboundList.tsx +++ b/frontend/src/pages/inbounds/list/InboundList.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState, type Key } from 'react'; +import { useLocation, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Button, @@ -58,7 +59,18 @@ export default function InboundList({ // Node filter (#4997): 'all' shows everything, 0 is the local-panel // sentinel (inbounds without a nodeId), otherwise a node id. Session-only. const [nodeFilter, setNodeFilter] = useState('all'); - const [searchKey, setSearchKey] = useState(''); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const searchParam = searchParams.get('search'); + const [searchKey, setSearchKey] = useState(() => searchParam || ''); + const [prevLocationKey, setPrevLocationKey] = useState(location.key); + + if (location.key !== prevLocationKey) { + setPrevLocationKey(location.key); + if (searchParam !== null) { + setSearchKey(searchParam); + } + } const showNodeFilter = useMemo( () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null), diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 234b9b53d..207e76e3c 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -108,6 +108,8 @@ export const InboundOptionSchema = z tag: z.string().optional(), protocol: z.string().optional(), port: z.number().optional(), + network: z.string().optional(), + security: z.string().optional(), tlsFlowCapable: z.boolean().optional(), ssMethod: z.string().optional(), wgPublicKey: z.string().optional(), diff --git a/frontend/src/test/app-sidebar.test.tsx b/frontend/src/test/app-sidebar.test.tsx index 51d06bc2a..723a7f805 100644 --- a/frontend/src/test/app-sidebar.test.tsx +++ b/frontend/src/test/app-sidebar.test.tsx @@ -65,3 +65,9 @@ test('returns to the compact rail after unpinning', () => { expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px'); expect(localStorage.getItem('sidebar-pinned')).toBe('false'); }); + +test('labels the palette shortcut with the modifier the platform actually uses', () => { + const view = renderSidebar(); + const chip = view.container.querySelector('.sidebar-command-kbd'); + expect(chip?.textContent).toBe('CtrlK'); +}); diff --git a/frontend/src/test/command-palette.test.tsx b/frontend/src/test/command-palette.test.tsx new file mode 100644 index 000000000..b48b19fb4 --- /dev/null +++ b/frontend/src/test/command-palette.test.tsx @@ -0,0 +1,330 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; + +import CommandPalette from '@/components/command-palette/CommandPalette'; +import { commandPaletteStore } from '@/components/command-palette/useCommandPalette'; +import { HttpUtil, Msg } from '@/utils'; +import { renderWithProviders } from './test-utils'; + +function renderPalette() { + return renderWithProviders( + + + , + ); +} + +describe('CommandPalette component', () => { + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + act(() => { + commandPaletteStore.close(); + }); + vi.clearAllMocks(); + }); + + it('does not render when closed', () => { + renderPalette(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('renders and focuses input when opened via store', async () => { + renderPalette(); + + act(() => { + commandPaletteStore.open(); + }); + + expect(screen.getByRole('dialog')).toBeTruthy(); + const input = screen.getByPlaceholderText(/Type a command or search/i); + expect(input).toBeTruthy(); + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); + }); + + it('toggles open and closed with Ctrl+K and Escape keyboard shortcuts', () => { + renderPalette(); + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', code: 'KeyK', ctrlKey: true })); + }); + expect(commandPaletteStore.getSnapshot()).toBe(true); + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + expect(commandPaletteStore.getSnapshot()).toBe(false); + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ن', code: 'KeyK', ctrlKey: true })); + }); + expect(commandPaletteStore.getSnapshot()).toBe(true); + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + expect(commandPaletteStore.getSnapshot()).toBe(false); + }); + + it('closes when clicking backdrop', () => { + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const backdrop = screen.getByRole('presentation'); + fireEvent.click(backdrop); + + expect(commandPaletteStore.getSnapshot()).toBe(false); + }); + + it('navigates items with ArrowDown and ArrowUp', () => { + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i); + const items = document.querySelectorAll('.command-palette-item'); + expect(items.length).toBeGreaterThan(0); + + expect(items[0]?.classList.contains('active')).toBe(true); + + fireEvent.keyDown(input, { key: 'ArrowDown' }); + const updatedItems = document.querySelectorAll('.command-palette-item'); + expect(updatedItems[1]?.classList.contains('active')).toBe(true); + + fireEvent.keyDown(input, { key: 'ArrowUp' }); + const reupdatedItems = document.querySelectorAll('.command-palette-item'); + expect(reupdatedItems[0]?.classList.contains('active')).toBe(true); + }); + + it('filters items when typing a search query', async () => { + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i); + fireEvent.change(input, { target: { value: 'settings' } }); + + expect(screen.getAllByText(/Panel Settings/i).length).toBeGreaterThan(0); + }); + + it('resets query on close and does not persist query on reopen', async () => { + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'settings' } }); + expect(input.value).toBe('settings'); + + act(() => { + commandPaletteStore.close(); + }); + + act(() => { + commandPaletteStore.open(); + }); + + const reopenedInput = screen.getByPlaceholderText( + /Type a command or search/i, + ) as HTMLInputElement; + expect(reopenedInput.value).toBe(''); + }); + + it('does not show spinning loader on whitespace-only input', () => { + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i); + fireEvent.change(input, { target: { value: ' ' } }); + + expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull(); + }); + + it('does not display stale client rows when a new search query is being fetched', async () => { + let resolveBob: ((val: Msg<{ items: unknown[] }>) => void) | undefined; + const bobPromise = new Promise>((resolve) => { + resolveBob = resolve; + }); + + vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => { + if (url.includes('/panel/api/inbounds/options')) { + return new Msg(true, '', []); + } + if (url.includes('search=ali')) { + return new Msg(true, '', { + items: [ + { + id: 1, + email: 'alice@example.com', + totalGB: 1000, + enable: true, + traffic: { up: 100, down: 200, total: 1000 }, + }, + ], + }); + } + if (url.includes('search=bob')) { + return bobPromise as Promise>; + } + return new Msg(true, '', {}); + }); + + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i); + + // Type 'ali' and wait for Alice to appear after debounce + fireEvent.change(input, { target: { value: 'ali' } }); + await waitFor( + () => { + expect(screen.getByText('alice@example.com')).toBeTruthy(); + }, + { timeout: 2000 }, + ); + + // Now type 'bob' + fireEvent.change(input, { target: { value: 'bob' } }); + + // Alice must vanish immediately upon new input + await waitFor(() => { + expect(screen.queryByText('alice@example.com')).toBeNull(); + }); + + // Wait past the 300ms debounce interval while bob fetch is still pending + await new Promise((resolve) => setTimeout(resolve, 350)); + + // Stale Alice row must STILL not be rendered + expect(screen.queryByText('alice@example.com')).toBeNull(); + + // Now resolve bob + act(() => { + resolveBob?.( + new Msg(true, '', { + items: [ + { + id: 2, + email: 'bob@example.com', + totalGB: 500, + enable: true, + traffic: { up: 50, down: 100, total: 500 }, + }, + ], + }), + ); + }); + + await waitFor( + () => { + expect(screen.getByText('bob@example.com')).toBeTruthy(); + }, + { timeout: 2000 }, + ); + expect(screen.queryByText('alice@example.com')).toBeNull(); + }); + + it('does not re-trigger loading when adding trailing whitespace to settled query', async () => { + const getSpy = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => { + if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []); + return new Msg(true, '', { items: [] }); + }); + + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i); + fireEvent.change(input, { target: { value: 'abc' } }); + + await waitFor( + () => { + const calls = getSpy.mock.calls.filter((c) => String(c[0]).includes('search=abc')).length; + expect(calls).toBe(1); + }, + { timeout: 2000 }, + ); + + // Add trailing whitespace + fireEvent.change(input, { target: { value: 'abc ' } }); + await new Promise((resolve) => setTimeout(resolve, 350)); + + // No extra search call because trimmed query has not changed + const callsAfterAbcSpace = getSpy.mock.calls.filter((c) => + String(c[0]).includes('search=abc'), + ).length; + expect(callsAfterAbcSpace).toBe(1); + expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull(); + }); + + it('keeps the row secondary action independent of the row control', async () => { + vi.spyOn(HttpUtil, 'post').mockImplementation( + async (url: string) => + new Msg(true, '', url.includes('/setting/all') ? { subURI: 'https://sub.example/' } : {}), + ); + vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => { + if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []); + if (url.includes('search=ali')) { + return new Msg(true, '', { + items: [ + { + id: 1, + email: 'alice@example.com', + subId: 'sub123', + enable: true, + totalGB: 0, + traffic: { up: 100, down: 200, total: 0 }, + }, + ], + }); + } + return new Msg(true, '', {}); + }); + + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i); + fireEvent.change(input, { target: { value: 'ali' } }); + await waitFor( + () => { + expect(screen.getByText('alice@example.com')).toBeTruthy(); + }, + { timeout: 2000 }, + ); + + const copyBtn = document.querySelector('.command-palette-action-btn'); + expect(copyBtn).toBeTruthy(); + expect(copyBtn?.parentElement?.closest('button')).toBeNull(); + + // Enter on the copy button must not also fire the row's own action. + fireEvent.keyDown(copyBtn as Element, { key: 'Enter' }); + expect(commandPaletteStore.getSnapshot()).toBe(true); + }); + + it('renders a single theme action item without duplicates', () => { + renderPalette(); + act(() => { + commandPaletteStore.open(); + }); + + const input = screen.getByPlaceholderText(/Type a command or search/i); + fireEvent.change(input, { target: { value: 'theme' } }); + + const themeItems = screen.getAllByText(/Theme/i); + expect(themeItems.length).toBe(1); + }); +}); diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index d24ec8294..66d62959c 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -334,6 +334,8 @@ type InboundOption struct { Protocol string `json:"protocol" example:"vless"` Port int `json:"port" example:"443"` Enable bool `json:"enable" example:"true"` + Network string `json:"network,omitempty"` + Security string `json:"security,omitempty"` TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"` SsMethod string `json:"ssMethod"` WgPublicKey string `json:"wgPublicKey,omitempty"` @@ -389,6 +391,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) out := make([]InboundOption, 0, len(rows)) for _, r := range rows { wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings) + netHint, secHint := inboundStreamHints(r.Protocol, r.StreamSettings, r.Settings) shareAddrStrategy := r.ShareAddrStrategy if shareAddrStrategy == "node" { shareAddrStrategy = "" @@ -400,6 +403,8 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) Protocol: r.Protocol, Port: r.Port, Enable: r.Enable, + Network: netHint, + Security: secHint, TlsFlowCapable: !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings), SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings), WgPublicKey: wgPublicKey, @@ -417,6 +422,44 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) return out, nil } +func inboundStreamHints(protocol string, streamSettings string, settings string) (string, string) { + p := strings.ToLower(protocol) + if p == "wireguard" || p == "amneziawg" || p == "hysteria" { + return "udp", "" + } + var netHint, secHint string + if strings.TrimSpace(streamSettings) != "" { + var raw struct { + Network string `json:"network"` + Security string `json:"security"` + } + if err := json.Unmarshal([]byte(streamSettings), &raw); err == nil { + netHint = raw.Network + secHint = raw.Security + } + } + if netHint == "" && strings.TrimSpace(settings) != "" { + var raw struct { + Network string `json:"network"` + AllowedNetwork string `json:"allowedNetwork"` + UDP bool `json:"udp"` + } + if err := json.Unmarshal([]byte(settings), &raw); err == nil { + if raw.Network != "" { + netHint = raw.Network + } else if raw.AllowedNetwork != "" { + netHint = raw.AllowedNetwork + } else if raw.UDP { + netHint = "tcp,udp" + } + } + } + if netHint == "" { + netHint = "tcp" + } + return netHint, secHint +} + func inboundWireguardHints(protocol string, settings string) (string, int, string) { if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" { return "", 0, "" diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 46333b618..82299d203 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2365,5 +2365,18 @@ "statusFailed": "فشل", "statusDown": "غير متصل", "statusUp": "متصل" + }, + "commandPalette": { + "title": "لوحة الأوامر", + "placeholder": "اكتب أمرًا أو ابحث عن العملاء، المنافذ، الصفحات...", + "search": "بحث...", + "navigation": "التنقل", + "actions": "الإجراءات", + "navigate": "تنقل", + "select": "تحديد", + "copySubscription": "نسخ الاشتراك", + "restartXray": "إعادة تشغيل خدمة Xray", + "restartXraySuccess": "تمت إعادة تشغيل خدمة Xray بنجاح", + "settings": "الإعدادات" } } diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 48c34a834..b26a190c5 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -2365,5 +2365,18 @@ "statusFailed": "FAILED", "statusDown": "DOWN", "statusUp": "UP" + }, + "commandPalette": { + "title": "Command Palette", + "placeholder": "Type a command or search clients, inbounds, pages...", + "search": "Search...", + "navigation": "Navigation", + "actions": "Actions", + "navigate": "Navigate", + "select": "Select", + "copySubscription": "Copy subscription", + "restartXray": "Restart Xray Service", + "restartXraySuccess": "Xray has been successfully relaunched", + "settings": "Settings" } } diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index d59d36209..18d14e69e 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -2365,5 +2365,18 @@ "statusFailed": "FALLIDO", "statusDown": "CAÍDO", "statusUp": "ACTIVO" + }, + "commandPalette": { + "title": "Paleta de comandos", + "placeholder": "Escriba un comando o busque clientes, inbounds, páginas...", + "search": "Buscar...", + "navigation": "Navegación", + "actions": "Acciones", + "navigate": "Navegar", + "select": "Seleccionar", + "copySubscription": "Copiar suscripción", + "restartXray": "Reiniciar servicio Xray", + "restartXraySuccess": "El servicio Xray se ha reiniciado con éxito", + "settings": "Configuración" } } diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 66e1b268d..4f965a947 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2365,5 +2365,18 @@ "statusFailed": "ناموفق", "statusDown": "قطع", "statusUp": "وصل" + }, + "commandPalette": { + "title": "پالت دستورات", + "placeholder": "جستجوی کلاینت‌ها، اینباندها، صفحات یا تایپ دستور...", + "search": "جستجو...", + "navigation": "ناوبری و صفحات", + "actions": "دستورات سیستمی", + "navigate": "پیمایش", + "select": "انتخاب", + "copySubscription": "کپی لینک اشتراک", + "restartXray": "راه‌اندازی مجدد سرویس Xray", + "restartXraySuccess": "سرویس Xray با موفقیت مجدداً راه‌اندازی شد", + "settings": "تنظیمات" } } diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index d71e64cf5..60b5ba6b6 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -2365,5 +2365,18 @@ "statusFailed": "GAGAL", "statusDown": "MATI", "statusUp": "AKTIF" + }, + "commandPalette": { + "title": "Palet Perintah", + "placeholder": "Ketik perintah atau cari klien, inbound, halaman...", + "search": "Cari...", + "navigation": "Navigasi", + "actions": "Tindakan", + "navigate": "Navigasi", + "select": "Pilih", + "copySubscription": "Salin langganan", + "restartXray": "Mulai Ulang Layanan Xray", + "restartXraySuccess": "Layanan Xray berhasil dimulai ulang", + "settings": "Pengaturan" } } diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index d4985ff33..785be61f1 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2365,5 +2365,18 @@ "statusFailed": "失敗", "statusDown": "ダウン", "statusUp": "アップ" + }, + "commandPalette": { + "title": "コマンドパレット", + "placeholder": "コマンドを入力するか、クライアント、インバウンド、ページを検索...", + "search": "検索...", + "navigation": "ナビゲーション", + "actions": "操作", + "navigate": "移動", + "select": "選択", + "copySubscription": "サブスクリプションをコピー", + "restartXray": "Xrayサービスを再起動", + "restartXraySuccess": "Xrayサービスが正常に再起動されました", + "settings": "設定" } } diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 7958208f6..607873355 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -2365,5 +2365,18 @@ "statusFailed": "FALHOU", "statusDown": "INATIVO", "statusUp": "ATIVO" + }, + "commandPalette": { + "title": "Paleta de comandos", + "placeholder": "Digite um comando ou busque clientes, inbounds, páginas...", + "search": "Buscar...", + "navigation": "Navegação", + "actions": "Ações", + "navigate": "Navegar", + "select": "Selecionar", + "copySubscription": "Copiar assinatura", + "restartXray": "Reiniciar serviço Xray", + "restartXraySuccess": "O serviço Xray foi reiniciado com sucesso", + "settings": "Configurações" } } diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 655f48748..fd9fc4c2c 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2365,5 +2365,18 @@ "statusFailed": "НЕУДАЧНО", "statusDown": "НЕДОСТУПЕН", "statusUp": "РАБОТАЕТ" + }, + "commandPalette": { + "title": "Командная строка", + "placeholder": "Введите команду или найдите клиентов, инбаунды, страницы...", + "search": "Поиск...", + "navigation": "Навигация", + "actions": "Действия", + "navigate": "Перейти", + "select": "Выбрать", + "copySubscription": "Копировать подписку", + "restartXray": "Перезапустить службу Xray", + "restartXraySuccess": "Служба Xray успешно перезапущена", + "settings": "Настройки" } } diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 6ab3d1cad..91def2c5a 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -2365,5 +2365,18 @@ "statusFailed": "BAŞARISIZ", "statusDown": "ÇEVRİMDIŞI", "statusUp": "ÇEVRİMİÇİ" + }, + "commandPalette": { + "title": "Komut Paleti", + "placeholder": "Komut yazın veya istemcileri, gelen bağlantıları, sayfaları arayın...", + "search": "Ara...", + "navigation": "Gezinme", + "actions": "İşlemler", + "navigate": "Gezin", + "select": "Seç", + "copySubscription": "Aboneliği kopyala", + "restartXray": "Xray Hizmetini Yeniden Başlat", + "restartXraySuccess": "Xray hizmeti başarıyla yeniden başlatıldı", + "settings": "Ayarlar" } } diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 7d4cf23d8..19676454a 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2365,5 +2365,18 @@ "statusFailed": "НЕВДАЛО", "statusDown": "НЕДОСТУПНО", "statusUp": "ДОСТУПНО" + }, + "commandPalette": { + "title": "Командний рядок", + "placeholder": "Введіть команду або знайдіть клієнтів, інбаунди, сторінки...", + "search": "Пошук...", + "navigation": "Навігація", + "actions": "Дії", + "navigate": "Перейти", + "select": "Вибрати", + "copySubscription": "Копіювати підписку", + "restartXray": "Перезапустити службу Xray", + "restartXraySuccess": "Службу Xray успішно перезапущено", + "settings": "Налаштування" } } diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 6266335ca..a470d99d9 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -2365,5 +2365,18 @@ "statusFailed": "THẤT BẠI", "statusDown": "NGỪNG HOẠT ĐỘNG", "statusUp": "HOẠT ĐỘNG" + }, + "commandPalette": { + "title": "Bảng lệnh", + "placeholder": "Nhập lệnh hoặc tìm kiếm khách hàng, cổng vào, trang...", + "search": "Tìm kiếm...", + "navigation": "Điều hướng", + "actions": "Hành động", + "navigate": "Điều hướng", + "select": "Chọn", + "copySubscription": "Sao chép liên kết gói", + "restartXray": "Khởi động lại dịch vụ Xray", + "restartXraySuccess": "Dịch vụ Xray đã được khởi động lại thành công", + "settings": "Cài đặt" } } diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index ee2337340..0c9596c38 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2365,5 +2365,18 @@ "statusFailed": "失败", "statusDown": "断开", "statusUp": "恢复" + }, + "commandPalette": { + "title": "命令面板", + "placeholder": "输入命令或搜索客户端、入站、页面...", + "search": "搜索...", + "navigation": "页面导航", + "actions": "系统操作", + "navigate": "导航", + "select": "选择", + "copySubscription": "复制订阅链接", + "restartXray": "重启 Xray 服务", + "restartXraySuccess": "Xray 服务已成功重启", + "settings": "设置" } } diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 9e8836180..c01ed3054 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2365,5 +2365,18 @@ "statusFailed": "失敗", "statusDown": "中斷", "statusUp": "恢復" + }, + "commandPalette": { + "title": "命令面板", + "placeholder": "輸入命令或搜尋客戶端、入站、頁面...", + "search": "搜尋...", + "navigation": "頁面導航", + "actions": "系統操作", + "navigate": "導航", + "select": "選擇", + "copySubscription": "複製訂閱連結", + "restartXray": "重啟 Xray 服務", + "restartXraySuccess": "Xray 服務已成功重啟", + "settings": "設定" } }