feat(ui): add global command palette (Ctrl+K) for fast navigation and search (#6352)

* feat(ui): add global command palette (Ctrl+K) for fast navigation and search

* fix(ui): address review feedback for shortcut listener, i18n parity, and search deep links

* fix(ui): resolve search routing, translation keys, and palette state reset

* fix(ui): improve command palette styling and sidebar transitions

* fix(ui): address review feedback for typecheck, codegen, debouncing, and state reset

* fix(ui): resolve effect state update warning and debounce reset in command palette

* fix(ui): address review feedback for stale client search results and theme action

* style(ui): apply oxfmt formatting to command palette and tests

* fix(deps): update js-yaml override to resolve audit advisory

* docs(api): sync the docs OpenAPI copy with the new InboundOption fields

Adding Network/Security to InboundOption regenerated
frontend/public/openapi.json, but docs/public/openapi.json is a
hand-kept copy of that file and nothing checks it: make verify never
reaches docs/, and docs-ci.yml fires only on docs/**. The two files were
byte-identical on main and had diverged here, so the published API
reference described a response shape the panel no longer returns.

Regenerating the MDX under docs/content/docs/en/reference/api/ produced
no change — the schema is read from the JSON at render time.

* fix(ui): unnest the command palette row control and label its shortcut

The palette row was a <button> wrapping the copy-subscription <button>.
Nested interactive content is invalid HTML and React 19 logs two errors
for it on every client result. The row is now a role="button" div using
activateOnKey, the pattern the rest of the panel already uses, with
line-height pinned so dropping the UA button style does not grow every
row. Its keydown handler ignores events bubbling from the nested button:
activateOnKey preventDefaults Enter, which would otherwise cancel the
browser's Enter-to-click on the copy button and navigate instead.

The sidebar chip hardcoded the Mac glyph while the handler accepts Ctrl
as well, so Linux and Windows operators were shown a key they do not
have; it now picks the modifier from the platform.

Also restores the comment on ClientsPage's debouncedSearch that the
deep-link change removed — the code it explains is unchanged.
This commit is contained in:
MRVX
2026-09-10 19:23:49 +03:30
committed by GitHub
parent 2dd903ea8e
commit fc08b53395
30 changed files with 2140 additions and 4 deletions
+8
View File
@@ -2744,6 +2744,9 @@
"mtprotoDomain": { "mtprotoDomain": {
"type": "string" "type": "string"
}, },
"network": {
"type": "string"
},
"nodeAddress": { "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.", "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" "type": "string"
@@ -2765,6 +2768,9 @@
"example": "VLESS-443", "example": "VLESS-443",
"type": "string" "type": "string"
}, },
"security": {
"type": "string"
},
"shareAddr": { "shareAddr": {
"type": "string" "type": "string"
}, },
@@ -4515,11 +4521,13 @@
"id": 1, "id": 1,
"listen": "", "listen": "",
"mtprotoDomain": "", "mtprotoDomain": "",
"network": "",
"nodeAddress": "", "nodeAddress": "",
"nodeId": null, "nodeId": null,
"port": 443, "port": 443,
"protocol": "vless", "protocol": "vless",
"remark": "VLESS-443", "remark": "VLESS-443",
"security": "",
"shareAddr": "", "shareAddr": "",
"shareAddrStrategy": "", "shareAddrStrategy": "",
"ssMethod": "", "ssMethod": "",
+8
View File
@@ -2744,6 +2744,9 @@
"mtprotoDomain": { "mtprotoDomain": {
"type": "string" "type": "string"
}, },
"network": {
"type": "string"
},
"nodeAddress": { "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.", "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" "type": "string"
@@ -2765,6 +2768,9 @@
"example": "VLESS-443", "example": "VLESS-443",
"type": "string" "type": "string"
}, },
"security": {
"type": "string"
},
"shareAddr": { "shareAddr": {
"type": "string" "type": "string"
}, },
@@ -4515,11 +4521,13 @@
"id": 1, "id": 1,
"listen": "", "listen": "",
"mtprotoDomain": "", "mtprotoDomain": "",
"network": "",
"nodeAddress": "", "nodeAddress": "",
"nodeId": null, "nodeId": null,
"port": 443, "port": 443,
"protocol": "vless", "protocol": "vless",
"remark": "VLESS-443", "remark": "VLESS-443",
"security": "",
"shareAddr": "", "shareAddr": "",
"shareAddrStrategy": "", "shareAddrStrategy": "",
"ssMethod": "", "ssMethod": "",
@@ -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 <button>; keep the UA line-height it had so swapping the
tag does not grow every row by the panel's body line-height. */
line-height: normal;
color: inherit;
transition:
background 0.12s ease,
color 0.12s ease;
user-select: none;
}
.command-palette-modal.light .command-palette-item.active {
background: #e6f4ff;
color: #0958d9;
}
.command-palette-modal.dark .command-palette-item.active {
background: rgba(22, 119, 255, 0.2);
color: #4096ff;
}
.command-palette-modal.ultra .command-palette-item.active {
background: rgba(22, 119, 255, 0.28);
color: #69b1ff;
}
.command-palette-item-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex: 1;
}
.command-palette-item-icon {
font-size: 20px;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.command-palette-item-content {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.command-palette-item-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.command-palette-item-subtitle {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.command-palette-modal.light .command-palette-item-subtitle {
color: #8c8c8c;
}
.command-palette-modal.dark .command-palette-item-subtitle,
.command-palette-modal.ultra .command-palette-item-subtitle {
color: #8c8c8c;
}
.command-palette-item-actions {
display: flex;
align-items: center;
gap: 8px;
margin-inline-start: 12px;
flex-shrink: 0;
}
.command-palette-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 15px;
transition: all 0.15s ease;
}
.command-palette-modal.light .command-palette-action-btn {
color: rgba(0, 0, 0, 0.4);
}
.command-palette-modal.light .command-palette-action-btn:hover {
color: #0958d9;
background: rgba(0, 0, 0, 0.06);
}
.command-palette-modal.dark .command-palette-action-btn {
color: rgba(255, 255, 255, 0.45);
}
.command-palette-modal.dark .command-palette-action-btn:hover {
color: #4096ff;
background: rgba(255, 255, 255, 0.1);
}
.command-palette-modal.ultra .command-palette-action-btn {
color: rgba(255, 255, 255, 0.5);
}
.command-palette-modal.ultra .command-palette-action-btn:hover {
color: #69b1ff;
background: rgba(255, 255, 255, 0.15);
}
.command-palette-item.active .command-palette-action-btn {
color: inherit;
opacity: 0.8;
}
.command-palette-item.active .command-palette-action-btn:hover {
opacity: 1;
}
.command-palette-tooltip {
z-index: 2500 !important;
}
.command-palette-empty {
padding: 36px 16px;
text-align: center;
font-size: 14px;
}
.command-palette-modal.light .command-palette-empty {
color: #8c8c8c;
}
.command-palette-modal.dark .command-palette-empty,
.command-palette-modal.ultra .command-palette-empty {
color: #8c8c8c;
}
/* --------------------------------------------------------------------------
Footer & Keyboard Badges
-------------------------------------------------------------------------- */
.command-palette-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 18px;
font-size: 11.5px;
border-top: 1px solid;
}
.command-palette-modal.light .command-palette-footer {
background: #fafafa;
border-top-color: rgba(0, 0, 0, 0.08);
color: #8c8c8c;
}
.command-palette-modal.dark .command-palette-footer {
background: rgba(21, 22, 26, 0.95);
border-top-color: rgba(255, 255, 255, 0.08);
color: #8c8c8c;
}
.command-palette-modal.ultra .command-palette-footer {
background: #050507;
border-top-color: rgba(255, 255, 255, 0.12);
color: #8c8c8c;
}
.command-palette-kbd-group {
display: flex;
align-items: center;
gap: 14px;
}
.command-palette-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 5px;
font-family: inherit;
font-size: 11.5px;
font-weight: 600;
line-height: 1;
border-radius: 4px;
margin-inline-end: 4px;
}
.command-palette-modal.light .command-palette-kbd {
background: #ffffff;
border: 1px solid #d9d9d9;
color: #595959;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.command-palette-modal.dark .command-palette-kbd {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #d9d9d9;
}
.command-palette-modal.ultra .command-palette-kbd {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #ffffff;
}
/* --------------------------------------------------------------------------
Sidebar Trigger Button
-------------------------------------------------------------------------- */
.sidebar-command-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: calc(100% - 16px);
height: 36px;
box-sizing: border-box;
margin: 8px 8px 4px;
padding: 0 10px;
border-radius: 7px;
font-size: 13px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
}
.sidebar-command-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.sidebar-command-trigger .sidebar-command-icon {
font-size: 15px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
}
.sidebar-command-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 1;
max-width: 130px;
transition:
opacity 0.2s ease,
max-width 0.2s ease;
}
.sidebar-command-trigger.collapsed {
justify-content: center;
padding: 0;
}
.sidebar-command-trigger.collapsed .sidebar-command-left {
justify-content: center;
width: 100%;
gap: 0;
}
.sidebar-command-trigger.collapsed .sidebar-command-icon {
margin: 0 auto;
font-size: 16px;
width: 16px;
height: 16px;
}
.sidebar-command-trigger.collapsed .sidebar-command-text {
display: none;
}
.sidebar-command-trigger.collapsed .sidebar-command-kbd {
display: none;
}
body.light .sidebar-command-trigger {
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.08);
color: #595959;
}
body.light .sidebar-command-trigger:hover {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.15);
color: #1f1f1f;
}
body.dark .sidebar-command-trigger {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #8c8c8c;
}
body.dark .sidebar-command-trigger:hover {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.18);
color: #ffffff;
}
.sidebar-command-trigger .sidebar-command-icon {
font-size: 15px;
}
.sidebar-command-trigger .sidebar-command-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2.5px;
height: 20px;
padding: 0 5px;
border-radius: 4px;
user-select: none;
}
.sidebar-command-trigger .sidebar-command-kbd .kbd-cmd {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 10px;
line-height: 1;
display: inline-block;
opacity: 0.9;
}
.sidebar-command-trigger .sidebar-command-kbd .kbd-key {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 11px;
font-weight: 700;
line-height: 1;
display: inline-block;
}
body.light .sidebar-command-trigger .sidebar-command-kbd {
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.12);
color: #595959;
}
body.dark .sidebar-command-trigger .sidebar-command-kbd {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #d9d9d9;
}
body.dark .sidebar-command-trigger:hover .sidebar-command-kbd {
border-color: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
@@ -0,0 +1,802 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CheckCircleFilled,
ClockCircleOutlined,
CloseCircleFilled,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
CopyOutlined,
DashboardOutlined,
DatabaseOutlined,
ExportOutlined,
FileTextOutlined,
GlobalOutlined,
ImportOutlined,
LoadingOutlined,
MailOutlined,
MessageOutlined,
MoonOutlined,
PlusOutlined,
ReloadOutlined,
SafetyOutlined,
SearchOutlined,
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
} from '@ant-design/icons';
import { ClipboardManager, HttpUtil, SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { useTheme } from '@/hooks/useTheme';
import type { ClientRecord, InboundOption } from '@/schemas/client';
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
import './CommandPalette.css';
interface PaletteItem {
id: string;
category: 'clients' | 'inbounds' | 'navigation' | 'settings' | 'actions';
title: string;
subtitle?: string;
keywords?: string[];
icon: ReactNode;
tag?: ReactNode;
action: () => void | Promise<void>;
secondaryAction?: {
label: string;
icon: ReactNode;
execute: (e: React.MouseEvent) => void;
};
}
export default function CommandPalette() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
const { isOpen, close } = useCommandPalette();
const { allSetting } = useAllSettings();
const { data: inbounds = [] } = useInboundOptions();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [clientSearch, setClientSearch] = useState<{ query: string; items: ClientRecord[] }>({
query: '',
items: [],
});
const [loadingClients, setLoadingClients] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
const isK = e.code === 'KeyK' || e.key === 'k' || e.key === 'K';
if ((e.metaKey || e.ctrlKey) && isK) {
e.preventDefault();
if (isOpen) {
close();
} else {
commandPaletteStore.open();
}
} else if (e.key === 'Escape' && isOpen) {
e.preventDefault();
close();
}
}
window.addEventListener('keydown', handleGlobalKeyDown, { capture: true });
return () => {
window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true });
};
}, [isOpen, close]);
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setQuery('');
setDebouncedQuery('');
setClientSearch({ query: '', items: [] });
setActiveIndex(0);
setLoadingClients(false);
}
}
const [prevQuery, setPrevQuery] = useState(query);
if (query !== prevQuery) {
setPrevQuery(query);
setActiveIndex(0);
if (!query.trim()) {
setDebouncedQuery('');
setClientSearch({ query: '', items: [] });
setLoadingClients(false);
}
}
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
const trimmed = query.trim();
if (!trimmed || trimmed === debouncedQuery) {
return;
}
const timer = window.setTimeout(() => {
setLoadingClients(true);
setDebouncedQuery(trimmed);
}, 300);
return () => {
window.clearTimeout(timer);
};
}, [isOpen, query, debouncedQuery]);
useEffect(() => {
if (!isOpen || debouncedQuery.length < 1) {
return;
}
let isCurrent = true;
const controller = new AbortController();
HttpUtil.get(
`/panel/api/clients/list/paged?search=${encodeURIComponent(debouncedQuery)}&pageSize=8`,
undefined,
{ silent: true, signal: controller.signal },
)
.then((msg) => {
if (!isCurrent) return;
if (
msg?.success &&
msg?.obj &&
Array.isArray((msg.obj as { items?: ClientRecord[] }).items)
) {
setClientSearch({
query: debouncedQuery,
items: (msg.obj as { items: ClientRecord[] }).items,
});
} else {
setClientSearch({ query: debouncedQuery, items: [] });
}
})
.finally(() => {
if (isCurrent) setLoadingClients(false);
});
return () => {
isCurrent = false;
controller.abort();
};
}, [isOpen, debouncedQuery]);
const copySubscription = useCallback(
async (client: ClientRecord) => {
if (!client.subId || !allSetting.subURI) {
message.warning(t('pages.clients.noSubId'));
return;
}
const link = `${allSetting.subURI}${client.subId}`;
const ok = await ClipboardManager.copyText(link);
if (ok) message.success(t('copied'));
},
[allSetting.subURI, t],
);
const restartXray = useCallback(async () => {
close();
const msg = await HttpUtil.post('/panel/api/server/restartXrayService', undefined, {
silentSuccess: true,
});
if (msg?.success) {
message.success(t('commandPalette.restartXraySuccess'));
}
}, [close, t]);
const cycleTheme = useCallback(() => {
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
close();
}, [isDark, isUltra, toggleTheme, toggleUltra, close]);
const trimmedQuery = query.trim();
const isDebouncing = isOpen && trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery;
const isClientSearching =
isOpen &&
trimmedQuery.length > 0 &&
(loadingClients || isDebouncing || clientSearch.query !== trimmedQuery);
const items = useMemo<PaletteItem[]>(() => {
const list: PaletteItem[] = [];
const q = query.trim().toLowerCase();
const matches = (title: string, subtitle?: string, keywords: string[] = []) => {
if (!q) return true;
if (title.toLowerCase().includes(q)) return true;
if (subtitle && subtitle.toLowerCase().includes(q)) return true;
return keywords.some((k) => k.toLowerCase().includes(q));
};
const trimmed = query.trim();
if (trimmed.length > 0 && clientSearch.query === trimmed && clientSearch.items.length > 0) {
clientSearch.items.forEach((c) => {
const up = Number(c.traffic?.up || 0);
const down = Number(c.traffic?.down || 0);
const total = Number(c.traffic?.total || c.totalGB || 0);
const trafficUsed = SizeFormatter.sizeFormat(up + down);
const trafficTotal = total > 0 ? SizeFormatter.sizeFormat(total) : '∞';
const isOnline = c.enable !== false;
list.push({
id: `client-${c.id ?? c.email}`,
category: 'clients',
title: c.email,
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
icon: isOnline ? (
<CheckCircleFilled style={{ color: '#52c41a' }} />
) : (
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
),
action: () => {
close();
navigate(`/clients?search=${encodeURIComponent(c.email)}`);
},
secondaryAction:
c.subId && allSetting.subURI
? {
label: t('commandPalette.copySubscription'),
icon: <CopyOutlined />,
execute: (e) => {
e.stopPropagation();
copySubscription(c);
},
}
: undefined,
});
});
}
const matchedInbounds = inbounds.filter((ib: InboundOption) => {
if (!q) return false;
return (
(ib.tag && ib.tag.toLowerCase().includes(q)) ||
(ib.remark && ib.remark.toLowerCase().includes(q)) ||
(ib.protocol && ib.protocol.toLowerCase().includes(q)) ||
(ib.port && String(ib.port).includes(q))
);
});
matchedInbounds.slice(0, 8).forEach((ib) => {
const tags: ReactNode[] = [];
if (ib.protocol) {
tags.push(
<Tag key="protocol" color="purple">
{ib.protocol}
</Tag>,
);
}
if (ib.network) {
const n = ib.network.toLowerCase();
let netLabel = n.toUpperCase();
if (n === 'httpupgrade') netLabel = 'HTTPUpgrade';
else if (n === 'splithttp') netLabel = 'SplitHTTP';
else if (n === 'xhttp') netLabel = 'XHTTP';
tags.push(
<Tag key="network" color="green">
{netLabel}
</Tag>,
);
}
if (ib.security && ib.security !== 'none') {
const s = ib.security.toLowerCase();
const secLabel = s === 'reality' ? 'Reality' : s === 'tls' ? 'TLS' : s.toUpperCase();
tags.push(
<Tag key="security" color="blue">
{secLabel}
</Tag>,
);
}
list.push({
id: `inbound-${ib.id}`,
category: 'inbounds',
title: ib.remark || ib.tag || `Inbound #${ib.id}`,
subtitle: `Port ${ib.port || ''}`,
icon: <ImportOutlined style={{ color: '#1677ff' }} />,
tag:
tags.length > 0 ? (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
) : undefined,
action: () => {
close();
navigate(`/inbounds?search=${encodeURIComponent(ib.remark || String(ib.port || ''))}`);
},
});
});
const pages = [
{
path: '/',
title: t('menu.dashboard'),
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
icon: <DashboardOutlined />,
},
{
path: '/inbounds',
title: t('menu.inbounds'),
keywords: [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
],
icon: <ImportOutlined />,
},
{
path: '/clients',
title: t('menu.clients'),
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
icon: <TeamOutlined />,
},
{
path: '/groups',
title: t('menu.groups'),
keywords: ['groups', 'tags', 'batch'],
icon: <TagsOutlined />,
},
{
path: '/nodes',
title: t('menu.nodes'),
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
icon: <ClusterOutlined />,
},
{
path: '/hosts',
title: t('menu.hosts'),
keywords: ['hosts', 'sni', 'domains'],
icon: <GlobalOutlined />,
},
{
path: '/outbound',
title: t('menu.outbounds'),
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
icon: <ExportOutlined />,
},
{
path: '/routing',
title: t('menu.routing'),
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
icon: <SwapOutlined />,
},
{
path: '/settings',
title: t('menu.settings'),
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
icon: <SettingOutlined />,
},
{
path: '/xray',
title: t('menu.xray'),
keywords: ['xray', 'templates', 'balancer', 'dns'],
icon: <ToolOutlined />,
},
{
path: '/api-docs',
title: t('menu.apiDocs'),
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined />,
},
];
pages
.filter((p) => matches(p.title, undefined, p.keywords))
.forEach((p) => {
list.push({
id: `nav-${p.path}`,
category: 'navigation',
title: p.title,
keywords: p.keywords,
icon: p.icon,
action: () => {
close();
navigate(p.path);
},
});
});
const settingsSubSections = [
{
path: '/settings#general',
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
subtitle: t('pages.settings.panelSettings'),
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
icon: <SettingOutlined />,
},
{
path: '/settings#security',
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
subtitle: t('pages.settings.securitySettings'),
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
icon: <SafetyOutlined />,
},
{
path: '/settings#telegram',
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
subtitle: t('pages.settings.TGBotSettings'),
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
icon: <MessageOutlined />,
},
{
path: '/settings#email',
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
subtitle: t('pages.settings.emailSettings'),
keywords: ['email', 'smtp', 'mail', 'crash alerts'],
icon: <MailOutlined />,
},
{
path: '/settings#subscription',
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
subtitle: t('pages.settings.subSettings'),
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
icon: <CloudServerOutlined />,
},
{
path: '/settings#subscription-formats',
title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
subtitle: t('menu.subFormats'),
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
icon: <CodeOutlined />,
},
{
path: '/settings#subscription-balancers',
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
subtitle: t('pages.settings.subBalancers.menu'),
keywords: ['balancers', 'sub balancers', 'balancer nodes'],
icon: <ApartmentOutlined />,
},
{
path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
subtitle: t('pages.xray.basicTemplate'),
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
icon: <ToolOutlined />,
},
{
path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.metricsListen')}`,
subtitle: t('pages.xray.metricsListen'),
keywords: [
'metrics',
'prometheus',
'statistics',
'listen',
'statsInbound',
'statsOutbound',
'metrics_out',
],
icon: <DashboardOutlined />,
},
{
path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
subtitle: t('pages.xray.connectionLimits'),
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
icon: <ClockCircleOutlined />,
},
{
path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
subtitle: t('pages.xray.logConfigs'),
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
icon: <FileTextOutlined />,
},
{
path: '/xray#balancer',
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
subtitle: t('pages.xray.Balancers'),
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
icon: <ClusterOutlined />,
},
{
path: '/xray#dns',
title: `${t('menu.xray')} · DNS`,
subtitle: 'DNS',
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
icon: <DatabaseOutlined />,
},
{
path: '/xray#outbound',
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
subtitle: t('pages.xray.Outbounds'),
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
icon: <ExportOutlined />,
},
{
path: '/xray#routing',
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
subtitle: t('pages.xray.basicRouting'),
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
icon: <SwapOutlined />,
},
{
path: '/xray#advanced',
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
subtitle: t('pages.xray.advancedTemplate'),
keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
icon: <CodeOutlined />,
},
];
settingsSubSections
.filter((s) => matches(s.title, s.subtitle, s.keywords))
.forEach((s) => {
list.push({
id: `setting-${s.path}-${s.title}`,
category: 'settings',
title: s.title,
subtitle: s.subtitle,
keywords: s.keywords,
icon: s.icon,
action: () => {
close();
navigate(s.path);
},
});
});
const actions: PaletteItem[] = [
{
id: 'act-restart-xray',
category: 'actions',
title: t('commandPalette.restartXray'),
subtitle: t('pages.index.restartXray'),
keywords: ['restart', 'xray restart', 'reboot xray'],
icon: <ReloadOutlined style={{ color: '#faad14' }} />,
action: restartXray,
},
{
id: 'act-cycle-theme',
category: 'actions',
title: t('menu.theme'),
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
keywords: ['theme', 'light', 'dark', 'ultra'],
icon: isDark ? <SunOutlined /> : <MoonOutlined />,
action: cycleTheme,
},
{
id: 'act-add-inbound',
category: 'actions',
title: t('pages.inbounds.addInbound'),
subtitle: t('menu.inbounds'),
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
action: () => {
close();
navigate('/inbounds');
},
},
{
id: 'act-add-client',
category: 'actions',
title: t('pages.clients.addClient'),
subtitle: t('menu.clients'),
keywords: ['add client', 'create user', 'new client', 'new user'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
action: () => {
close();
navigate('/clients');
},
},
];
actions.filter((a) => matches(a.title, a.subtitle, a.keywords)).forEach((a) => list.push(a));
return list;
}, [
query,
clientSearch,
inbounds,
isDark,
isUltra,
allSetting.subURI,
t,
close,
navigate,
copySubscription,
restartXray,
cycleTheme,
]);
const clampedActiveIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
useEffect(() => {
if (!listRef.current) return;
const activeEl = listRef.current.querySelector(
`.command-palette-item[data-index="${clampedActiveIndex}"]`,
) as HTMLElement | null;
if (activeEl) {
activeEl.scrollIntoView({ block: 'nearest' });
}
}, [clampedActiveIndex]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (items.length ? (prev + 1) % items.length : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (items.length ? (prev - 1 + items.length) % items.length : 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const current = items[clampedActiveIndex];
if (current) current.action();
}
};
if (!isOpen) return null;
let lastCategory = '';
const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
return (
<ConfigProvider theme={antdThemeConfig}>
<div
className={`command-palette-backdrop ${themeModeClass}`}
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div
className={`command-palette-modal ${themeModeClass}`}
role="dialog"
aria-modal="true"
aria-label={t('commandPalette.title')}
>
<div className="command-palette-header">
{isClientSearching ? (
<LoadingOutlined className="command-palette-search-icon spinning" />
) : (
<SearchOutlined className="command-palette-search-icon" />
)}
<input
ref={inputRef}
className="command-palette-input"
type="text"
placeholder={t('commandPalette.placeholder')}
value={query}
onChange={(e) => {
setQuery(e.target.value);
}}
onKeyDown={handleKeyDown}
/>
</div>
<div className="command-palette-body" ref={listRef}>
{!isClientSearching && items.length === 0 && (
<div className="command-palette-empty">{t('noData')}</div>
)}
{items.map((item, index) => {
const isFirstOfCategory = item.category !== lastCategory;
lastCategory = item.category;
const categoryLabel =
item.category === 'clients'
? t('menu.clients')
: item.category === 'inbounds'
? t('menu.inbounds')
: item.category === 'navigation'
? t('commandPalette.navigation')
: item.category === 'settings'
? t('commandPalette.settings') || t('menu.settings')
: t('commandPalette.actions');
return (
<div key={item.id} className="command-palette-group">
{isFirstOfCategory && (
<div className="command-palette-group-title">{categoryLabel}</div>
)}
<div
role="button"
tabIndex={0}
className={`command-palette-item ${index === clampedActiveIndex ? 'active' : ''}`}
data-index={index}
onClick={() => item.action()}
onKeyDown={(e) => {
// Enter on the nested copy button must activate that
// button, not the row it sits in.
if (e.target === e.currentTarget) activateOnKey(() => item.action())(e);
}}
onMouseEnter={() => setActiveIndex(index)}
>
<div className="command-palette-item-main">
<span className="command-palette-item-icon">{item.icon}</span>
<div className="command-palette-item-content">
<span className="command-palette-item-title">{item.title}</span>
{item.subtitle && (
<span className="command-palette-item-subtitle">{item.subtitle}</span>
)}
</div>
</div>
<div className="command-palette-item-actions">
{item.tag}
{item.secondaryAction && (
<Tooltip
title={item.secondaryAction.label}
placement="top"
zIndex={2500}
rootClassName="command-palette-tooltip"
>
<button
type="button"
className="command-palette-action-btn"
onClick={item.secondaryAction.execute}
aria-label={item.secondaryAction.label}
>
{item.secondaryAction.icon}
</button>
</Tooltip>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="command-palette-footer">
<div className="command-palette-kbd-group">
<span>
<kbd className="command-palette-kbd"></kbd>
<kbd className="command-palette-kbd"></kbd>
{t('commandPalette.navigate')}
</span>
<span>
<kbd className="command-palette-kbd"></kbd>
{t('commandPalette.select')}
</span>
<span>
<kbd className="command-palette-kbd">Esc</kbd>
{t('close')}
</span>
</div>
<span>3x-ui Command Palette</span>
</div>
</div>
</div>
</ConfigProvider>
);
}
@@ -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,
};
}
+2
View File
@@ -712,11 +712,13 @@ export const EXAMPLES: Record<string, unknown> = {
"id": 1, "id": 1,
"listen": "", "listen": "",
"mtprotoDomain": "", "mtprotoDomain": "",
"network": "",
"nodeAddress": "", "nodeAddress": "",
"nodeId": null, "nodeId": null,
"port": 443, "port": 443,
"protocol": "vless", "protocol": "vless",
"remark": "VLESS-443", "remark": "VLESS-443",
"security": "",
"shareAddr": "", "shareAddr": "",
"shareAddrStrategy": "", "shareAddrStrategy": "",
"ssMethod": "", "ssMethod": "",
+6
View File
@@ -2718,6 +2718,9 @@ export const SCHEMAS: Record<string, unknown> = {
"mtprotoDomain": { "mtprotoDomain": {
"type": "string" "type": "string"
}, },
"network": {
"type": "string"
},
"nodeAddress": { "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.", "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" "type": "string"
@@ -2739,6 +2742,9 @@ export const SCHEMAS: Record<string, unknown> = {
"example": "VLESS-443", "example": "VLESS-443",
"type": "string" "type": "string"
}, },
"security": {
"type": "string"
},
"shareAddr": { "shareAddr": {
"type": "string" "type": "string"
}, },
+2
View File
@@ -622,11 +622,13 @@ export interface InboundOption {
id: number; id: number;
listen?: string; listen?: string;
mtprotoDomain?: string; mtprotoDomain?: string;
network?: string;
nodeAddress?: string; nodeAddress?: string;
nodeId?: number | null; nodeId?: number | null;
port: number; port: number;
protocol: string; protocol: string;
remark: string; remark: string;
security?: string;
shareAddr?: string; shareAddr?: string;
shareAddrStrategy?: string; shareAddrStrategy?: string;
ssMethod: string; ssMethod: string;
+2
View File
@@ -664,11 +664,13 @@ export const InboundOptionSchema = z.object({
id: z.number().int(), id: z.number().int(),
listen: z.string().optional(), listen: z.string().optional(),
mtprotoDomain: z.string().optional(), mtprotoDomain: z.string().optional(),
network: z.string().optional(),
nodeAddress: z.string().optional(), nodeAddress: z.string().optional(),
nodeId: z.number().int().nullable().optional(), nodeId: z.number().int().nullable().optional(),
port: z.number().int(), port: z.number().int(),
protocol: z.string(), protocol: z.string(),
remark: z.string(), remark: z.string(),
security: z.string().optional(),
shareAddr: z.string().optional(), shareAddr: z.string().optional(),
shareAddrStrategy: z.string().optional(), shareAddrStrategy: z.string().optional(),
ssMethod: z.string(), ssMethod: z.string(),
+50 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, CSSProperties } from 'react'; import type { ComponentType, CSSProperties } from 'react';
import { useLocation, useNavigate } from 'react-router'; import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd'; import { Drawer, Layout, Menu, Tooltip } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
ApiOutlined, ApiOutlined,
@@ -28,6 +28,7 @@ import {
PushpinOutlined, PushpinOutlined,
ReadOutlined, ReadOutlined,
SafetyOutlined, SafetyOutlined,
SearchOutlined,
SettingOutlined, SettingOutlined,
SunOutlined, SunOutlined,
SwapOutlined, SwapOutlined,
@@ -40,9 +41,13 @@ import { HttpUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version'; import { formatPanelVersion } from '@/lib/panel-version';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings'; import { useAllSettings } from '@/api/queries/useAllSettings';
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
import './AppSidebar.css'; import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/'; 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 DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui'; const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__'; const LOGOUT_KEY = '__logout__';
@@ -174,6 +179,7 @@ function saveSidebarPinned(pinned: boolean) {
export default function AppSidebar() { export default function AppSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const { open: openCommandPalette } = useCommandPalette();
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname, hash } = useLocation(); const { pathname, hash } = useLocation();
const { allSetting } = useAllSettings(); const { allSetting } = useAllSettings();
@@ -392,6 +398,30 @@ export default function AppSidebar() {
</div> </div>
)} )}
</div> </div>
<Tooltip
title={
railCollapsed ? t('commandPalette.title') || 'Command Palette (Ctrl + K)' : undefined
}
placement="right"
>
<button
type="button"
className={`sidebar-command-trigger${railCollapsed ? ' collapsed' : ''}`}
onClick={openCommandPalette}
aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'}
>
<span className="sidebar-command-left">
<SearchOutlined className="sidebar-command-icon" />
<span className="sidebar-command-text">
{t('commandPalette.search') || 'Search...'}
</span>
</span>
<span className="sidebar-command-kbd">
<span className="kbd-cmd">{SHORTCUT_MODIFIER}</span>
<span className="kbd-key">K</span>
</span>
</button>
</Tooltip>
<Menu <Menu
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
@@ -452,6 +482,25 @@ export default function AppSidebar() {
</button> </button>
</div> </div>
</div> </div>
<button
type="button"
className="sidebar-command-trigger"
onClick={() => {
setDrawerOpen(false);
openCommandPalette();
}}
aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'}
style={{ margin: '8px 12px 4px', width: 'calc(100% - 24px)' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SearchOutlined className="sidebar-command-icon" />
<span>{t('commandPalette.search') || 'Search...'}</span>
</span>
<span className="sidebar-command-kbd">
<span className="kbd-cmd">{SHORTCUT_MODIFIER}</span>
<span className="kbd-key">K</span>
</span>
</button>
<Menu <Menu
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
+7 -1
View File
@@ -2,9 +2,15 @@ import { Outlet } from 'react-router';
import { useWebSocketBridge } from '@/api/websocketBridge'; import { useWebSocketBridge } from '@/api/websocketBridge';
import { usePageTitle } from '@/hooks/usePageTitle'; import { usePageTitle } from '@/hooks/usePageTitle';
import CommandPalette from '@/components/command-palette/CommandPalette';
export default function PanelLayout() { export default function PanelLayout() {
useWebSocketBridge(); useWebSocketBridge();
usePageTitle(); usePageTitle();
return <Outlet />; return (
<>
<Outlet />
<CommandPalette />
</>
);
} }
+16 -1
View File
@@ -1,4 +1,5 @@
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Badge, Badge,
@@ -382,7 +383,12 @@ export default function ClientsPage() {
>(null); >(null);
const initial = readFilterState(); 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<ClientFilters>(initial.filters); const [filters, setFilters] = useState<ClientFilters>(initial.filters);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); 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 // debouncedSearch lags behind the input so we don't spam the server on every
// keystroke; the search box still feels instant locally. // keystroke; the search box still feels instant locally.
const [debouncedSearch, setDebouncedSearch] = useState(searchKey); 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(() => { useEffect(() => {
localStorage.setItem( localStorage.setItem(
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState, type Key } from 'react'; import { useCallback, useMemo, useState, type Key } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
@@ -58,7 +59,18 @@ export default function InboundList({
// Node filter (#4997): 'all' shows everything, 0 is the local-panel // Node filter (#4997): 'all' shows everything, 0 is the local-panel
// sentinel (inbounds without a nodeId), otherwise a node id. Session-only. // sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all'); const [nodeFilter, setNodeFilter] = useState<number | 'all'>('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( const showNodeFilter = useMemo(
() => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null), () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
+2
View File
@@ -108,6 +108,8 @@ export const InboundOptionSchema = z
tag: z.string().optional(), tag: z.string().optional(),
protocol: z.string().optional(), protocol: z.string().optional(),
port: z.number().optional(), port: z.number().optional(),
network: z.string().optional(),
security: z.string().optional(),
tlsFlowCapable: z.boolean().optional(), tlsFlowCapable: z.boolean().optional(),
ssMethod: z.string().optional(), ssMethod: z.string().optional(),
wgPublicKey: z.string().optional(), wgPublicKey: z.string().optional(),
+6
View File
@@ -65,3 +65,9 @@ test('returns to the compact rail after unpinning', () => {
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px'); expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
expect(localStorage.getItem('sidebar-pinned')).toBe('false'); 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');
});
+330
View File
@@ -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(
<MemoryRouter>
<CommandPalette />
</MemoryRouter>,
);
}
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<Msg<{ items: unknown[] }>>((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<Msg<unknown>>;
}
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);
});
});
+43
View File
@@ -334,6 +334,8 @@ type InboundOption struct {
Protocol string `json:"protocol" example:"vless"` Protocol string `json:"protocol" example:"vless"`
Port int `json:"port" example:"443"` Port int `json:"port" example:"443"`
Enable bool `json:"enable" example:"true"` Enable bool `json:"enable" example:"true"`
Network string `json:"network,omitempty"`
Security string `json:"security,omitempty"`
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"` TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
SsMethod string `json:"ssMethod"` SsMethod string `json:"ssMethod"`
WgPublicKey string `json:"wgPublicKey,omitempty"` WgPublicKey string `json:"wgPublicKey,omitempty"`
@@ -389,6 +391,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
out := make([]InboundOption, 0, len(rows)) out := make([]InboundOption, 0, len(rows))
for _, r := range rows { for _, r := range rows {
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings) wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
netHint, secHint := inboundStreamHints(r.Protocol, r.StreamSettings, r.Settings)
shareAddrStrategy := r.ShareAddrStrategy shareAddrStrategy := r.ShareAddrStrategy
if shareAddrStrategy == "node" { if shareAddrStrategy == "node" {
shareAddrStrategy = "" shareAddrStrategy = ""
@@ -400,6 +403,8 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Protocol: r.Protocol, Protocol: r.Protocol,
Port: r.Port, Port: r.Port,
Enable: r.Enable, Enable: r.Enable,
Network: netHint,
Security: secHint,
TlsFlowCapable: !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings), TlsFlowCapable: !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings), SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
WgPublicKey: wgPublicKey, WgPublicKey: wgPublicKey,
@@ -417,6 +422,44 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
return out, nil 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) { func inboundWireguardHints(protocol string, settings string) (string, int, string) {
if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" { if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
return "", 0, "" return "", 0, ""
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "فشل", "statusFailed": "فشل",
"statusDown": "غير متصل", "statusDown": "غير متصل",
"statusUp": "متصل" "statusUp": "متصل"
},
"commandPalette": {
"title": "لوحة الأوامر",
"placeholder": "اكتب أمرًا أو ابحث عن العملاء، المنافذ، الصفحات...",
"search": "بحث...",
"navigation": "التنقل",
"actions": "الإجراءات",
"navigate": "تنقل",
"select": "تحديد",
"copySubscription": "نسخ الاشتراك",
"restartXray": "إعادة تشغيل خدمة Xray",
"restartXraySuccess": "تمت إعادة تشغيل خدمة Xray بنجاح",
"settings": "الإعدادات"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "FAILED", "statusFailed": "FAILED",
"statusDown": "DOWN", "statusDown": "DOWN",
"statusUp": "UP" "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"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "FALLIDO", "statusFailed": "FALLIDO",
"statusDown": "CAÍDO", "statusDown": "CAÍDO",
"statusUp": "ACTIVO" "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"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "ناموفق", "statusFailed": "ناموفق",
"statusDown": "قطع", "statusDown": "قطع",
"statusUp": "وصل" "statusUp": "وصل"
},
"commandPalette": {
"title": "پالت دستورات",
"placeholder": "جستجوی کلاینت‌ها، اینباندها، صفحات یا تایپ دستور...",
"search": "جستجو...",
"navigation": "ناوبری و صفحات",
"actions": "دستورات سیستمی",
"navigate": "پیمایش",
"select": "انتخاب",
"copySubscription": "کپی لینک اشتراک",
"restartXray": "راه‌اندازی مجدد سرویس Xray",
"restartXraySuccess": "سرویس Xray با موفقیت مجدداً راه‌اندازی شد",
"settings": "تنظیمات"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "GAGAL", "statusFailed": "GAGAL",
"statusDown": "MATI", "statusDown": "MATI",
"statusUp": "AKTIF" "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"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "失敗", "statusFailed": "失敗",
"statusDown": "ダウン", "statusDown": "ダウン",
"statusUp": "アップ" "statusUp": "アップ"
},
"commandPalette": {
"title": "コマンドパレット",
"placeholder": "コマンドを入力するか、クライアント、インバウンド、ページを検索...",
"search": "検索...",
"navigation": "ナビゲーション",
"actions": "操作",
"navigate": "移動",
"select": "選択",
"copySubscription": "サブスクリプションをコピー",
"restartXray": "Xrayサービスを再起動",
"restartXraySuccess": "Xrayサービスが正常に再起動されました",
"settings": "設定"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "FALHOU", "statusFailed": "FALHOU",
"statusDown": "INATIVO", "statusDown": "INATIVO",
"statusUp": "ATIVO" "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"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "НЕУДАЧНО", "statusFailed": "НЕУДАЧНО",
"statusDown": "НЕДОСТУПЕН", "statusDown": "НЕДОСТУПЕН",
"statusUp": "РАБОТАЕТ" "statusUp": "РАБОТАЕТ"
},
"commandPalette": {
"title": "Командная строка",
"placeholder": "Введите команду или найдите клиентов, инбаунды, страницы...",
"search": "Поиск...",
"navigation": "Навигация",
"actions": "Действия",
"navigate": "Перейти",
"select": "Выбрать",
"copySubscription": "Копировать подписку",
"restartXray": "Перезапустить службу Xray",
"restartXraySuccess": "Служба Xray успешно перезапущена",
"settings": "Настройки"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "BAŞARISIZ", "statusFailed": "BAŞARISIZ",
"statusDown": "ÇEVRİMDIŞI", "statusDown": "ÇEVRİMDIŞI",
"statusUp": "ÇEVRİMİÇİ" "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"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "НЕВДАЛО", "statusFailed": "НЕВДАЛО",
"statusDown": "НЕДОСТУПНО", "statusDown": "НЕДОСТУПНО",
"statusUp": "ДОСТУПНО" "statusUp": "ДОСТУПНО"
},
"commandPalette": {
"title": "Командний рядок",
"placeholder": "Введіть команду або знайдіть клієнтів, інбаунди, сторінки...",
"search": "Пошук...",
"navigation": "Навігація",
"actions": "Дії",
"navigate": "Перейти",
"select": "Вибрати",
"copySubscription": "Копіювати підписку",
"restartXray": "Перезапустити службу Xray",
"restartXraySuccess": "Службу Xray успішно перезапущено",
"settings": "Налаштування"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "THẤT BẠI", "statusFailed": "THẤT BẠI",
"statusDown": "NGỪNG HOẠT ĐỘNG", "statusDown": "NGỪNG HOẠT ĐỘNG",
"statusUp": "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"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "失败", "statusFailed": "失败",
"statusDown": "断开", "statusDown": "断开",
"statusUp": "恢复" "statusUp": "恢复"
},
"commandPalette": {
"title": "命令面板",
"placeholder": "输入命令或搜索客户端、入站、页面...",
"search": "搜索...",
"navigation": "页面导航",
"actions": "系统操作",
"navigate": "导航",
"select": "选择",
"copySubscription": "复制订阅链接",
"restartXray": "重启 Xray 服务",
"restartXraySuccess": "Xray 服务已成功重启",
"settings": "设置"
} }
} }
+13
View File
@@ -2365,5 +2365,18 @@
"statusFailed": "失敗", "statusFailed": "失敗",
"statusDown": "中斷", "statusDown": "中斷",
"statusUp": "恢復" "statusUp": "恢復"
},
"commandPalette": {
"title": "命令面板",
"placeholder": "輸入命令或搜尋客戶端、入站、頁面...",
"search": "搜尋...",
"navigation": "頁面導航",
"actions": "系統操作",
"navigate": "導航",
"select": "選擇",
"copySubscription": "複製訂閱連結",
"restartXray": "重啟 Xray 服務",
"restartXraySuccess": "Xray 服務已成功重啟",
"settings": "設定"
} }
} }