Browse Source

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.
MRVX 8 hours ago
parent
commit
fc08b53395

+ 8 - 0
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": "",

+ 8 - 0
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": "",

+ 625 - 0
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 <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;
+}

+ 802 - 0
frontend/src/components/command-palette/CommandPalette.tsx

@@ -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>
+  );
+}

+ 49 - 0
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,
+  };
+}

+ 2 - 0
frontend/src/generated/examples.ts

@@ -712,11 +712,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "id": 1,
     "listen": "",
     "mtprotoDomain": "",
+    "network": "",
     "nodeAddress": "",
     "nodeId": null,
     "port": 443,
     "protocol": "vless",
     "remark": "VLESS-443",
+    "security": "",
     "shareAddr": "",
     "shareAddrStrategy": "",
     "ssMethod": "",

+ 6 - 0
frontend/src/generated/schemas.ts

@@ -2718,6 +2718,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "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<string, unknown> = {
         "example": "VLESS-443",
         "type": "string"
       },
+      "security": {
+        "type": "string"
+      },
       "shareAddr": {
         "type": "string"
       },

+ 2 - 0
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;

+ 2 - 0
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(),

+ 50 - 1
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() {
             </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
           theme={currentTheme}
           mode="inline"
@@ -452,6 +482,25 @@ export default function AppSidebar() {
             </button>
           </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
           theme={currentTheme}
           mode="inline"

+ 7 - 1
frontend/src/layouts/PanelLayout.tsx

@@ -2,9 +2,15 @@ import { Outlet } from 'react-router';
 
 import { useWebSocketBridge } from '@/api/websocketBridge';
 import { usePageTitle } from '@/hooks/usePageTitle';
+import CommandPalette from '@/components/command-palette/CommandPalette';
 
 export default function PanelLayout() {
   useWebSocketBridge();
   usePageTitle();
-  return <Outlet />;
+  return (
+    <>
+      <Outlet />
+      <CommandPalette />
+    </>
+  );
 }

+ 16 - 1
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<ClientFilters>(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(

+ 13 - 1
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<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(
     () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),

+ 2 - 0
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(),

+ 6 - 0
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');
+});

+ 330 - 0
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(
+    <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: '[email protected]',
+              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('[email protected]')).toBeTruthy();
+      },
+      { timeout: 2000 },
+    );
+
+    // Now type 'bob'
+    fireEvent.change(input, { target: { value: 'bob' } });
+
+    // Alice must vanish immediately upon new input
+    await waitFor(() => {
+      expect(screen.queryByText('[email protected]')).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('[email protected]')).toBeNull();
+
+    // Now resolve bob
+    act(() => {
+      resolveBob?.(
+        new Msg(true, '', {
+          items: [
+            {
+              id: 2,
+              email: '[email protected]',
+              totalGB: 500,
+              enable: true,
+              traffic: { up: 50, down: 100, total: 500 },
+            },
+          ],
+        }),
+      );
+    });
+
+    await waitFor(
+      () => {
+        expect(screen.getByText('[email protected]')).toBeTruthy();
+      },
+      { timeout: 2000 },
+    );
+    expect(screen.queryByText('[email protected]')).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: '[email protected]',
+              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('[email protected]')).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 - 0
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, ""

+ 13 - 0
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": "الإعدادات"
   }
 }

+ 13 - 0
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"
   }
 }

+ 13 - 0
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"
   }
 }

+ 13 - 0
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": "تنظیمات"
   }
 }

+ 13 - 0
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"
   }
 }

+ 13 - 0
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": "設定"
   }
 }

+ 13 - 0
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"
   }
 }

+ 13 - 0
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": "Настройки"
   }
 }

+ 13 - 0
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"
   }
 }

+ 13 - 0
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": "Налаштування"
   }
 }

+ 13 - 0
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"
   }
 }

+ 13 - 0
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": "设置"
   }
 }

+ 13 - 0
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": "設定"
   }
 }