From adc262a23875c8c2d107a02209633aaf65a162d3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 13 May 2026 21:13:16 +0200 Subject: [PATCH 01/14] fix(warp): set license against Cloudflare API and surface errors inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The license update was always failing because the Cloudflare response has no `success` field — the check rejected every successful PUT. On real errors (e.g. "Too many connected devices."), the toast leaked the raw URL + JSON body. Now the WARP API's error envelope is parsed into a clean message and shown inline next to the Update button. --- frontend/src/pages/xray/WarpModal.vue | 26 +++++++++++++++++++++++--- web/service/warp.go | 27 ++++++++++++++++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/xray/WarpModal.vue b/frontend/src/pages/xray/WarpModal.vue index c42ede75..0192ebf9 100644 --- a/frontend/src/pages/xray/WarpModal.vue +++ b/frontend/src/pages/xray/WarpModal.vue @@ -27,6 +27,7 @@ const loading = ref(false); const warpData = ref(null); const warpConfig = ref(null); const warpPlus = ref(''); +const licenseError = ref(''); // Held in memory so the parent's add/reset handlers receive the same // object the modal computed from getConfig(). const stagedOutbound = ref(null); @@ -41,6 +42,7 @@ watch(() => props.open, (next) => { if (!next) return; warpConfig.value = null; stagedOutbound.value = null; + licenseError.value = ''; fetchData(); }); @@ -89,12 +91,15 @@ async function getConfig() { async function updateLicense() { if (warpPlus.value.length < 26) return; loading.value = true; + licenseError.value = ''; try { const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value }); if (msg?.success) { warpData.value = JSON.parse(msg.obj); warpConfig.value = null; warpPlus.value = ''; + } else { + licenseError.value = msg?.msg || 'Failed to set WARP license.'; } } finally { loading.value = false; @@ -233,9 +238,12 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value)); - - Update + +
+ Update + +
@@ -358,4 +366,16 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value)); .ml-8 { margin-left: 8px; } + +.license-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.license-error { + flex: 1; + min-width: 0; +} diff --git a/web/service/warp.go b/web/service/warp.go index 6d36774a..fbe39957 100644 --- a/web/service/warp.go +++ b/web/service/warp.go @@ -152,13 +152,8 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) { if err := json.Unmarshal(body, &response); err != nil { return "", err } - if success, _ := response["success"].(bool); !success { - if errorArr, ok := response["errors"].([]any); ok && len(errorArr) > 0 { - if errorObj, ok := errorArr[0].(map[string]any); ok { - return "", common.NewError(errorObj["code"], errorObj["message"]) - } - } - return "", common.NewError("warp set license failed: unknown error") + if _, ok := response["id"].(string); !ok { + return "", common.NewErrorf("warp set license failed: unexpected response: %s", string(body)) } warpData["license_key"] = license @@ -202,8 +197,26 @@ func doWarpRequest(req *http.Request) ([]byte, error) { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if msg := parseWarpError(body); msg != "" { + return nil, common.NewError(msg) + } return nil, common.NewErrorf("warp api %s %s returned status %d: %s", req.Method, req.URL.Path, resp.StatusCode, string(body)) } return body, nil } + +func parseWarpError(body []byte) string { + var env struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &env); err != nil { + return "" + } + if len(env.Errors) == 0 || env.Errors[0].Message == "" { + return "" + } + return env.Errors[0].Message +} From 61ab60288706b8ad784b2cd8c4cd010c2cd7d7b4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 13 May 2026 21:32:13 +0200 Subject: [PATCH 02/14] fix(iplog): parse xray access-log timestamps in local time Xray writes access-log timestamps in the server's local timezone, but time.Parse interpreted them as UTC, shifting the stored unix epoch by the host offset. The panel rendered the epoch back to local time, so CST users saw IP-log times 8 hours in the future. Parse the log timestamp with time.ParseInLocation(time.Local) so it round-trips. Fixes #4147 --- web/job/check_client_ip_job.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 3f1064ff..644a717b 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -181,7 +181,7 @@ func (j *CheckClientIpJob) processLogFile() bool { var timestamp int64 timestampMatches := timestampRegex.FindStringSubmatch(line) if len(timestampMatches) >= 2 { - t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1]) + t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local) if err == nil { timestamp = t.Unix() } else { From 771bc7c8ef641bdfb171e84826df7ccc62a0ef1c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 13 May 2026 22:44:08 +0200 Subject: [PATCH 03/14] feat(inbounds): align tunnel, tun, and hysteria UI with Xray docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tunnel: rename settings to Xray's current schema (address → rewriteAddress, port → rewritePort, network → allowedNetwork) in the model, form modal, info modal, and the bundled API inbound template; expose portMap so per-port forwarding can be configured from the panel. * tun: add the full TUN protocol form and read-only info blocks (name, mtu, gateway, dns, userLevel, autoSystemRoutingTable, autoOutboundsInterface) — previously the protocol was selectable but the form rendered blank. * hysteria: surface the stream-level version, obfs password, and udpIdleTimeout fields that the model already supported. Refs https://xtls.github.io/config/inbounds/tunnel.html Refs https://xtls.github.io/config/inbounds/tun.html Refs https://xtls.github.io/config/transports/hysteria.html --- frontend/src/models/inbound.js | 32 ++-- .../src/pages/inbounds/InboundFormModal.vue | 147 ++++++++++++++++-- .../src/pages/inbounds/InboundInfoModal.vue | 37 ++++- web/service/config.json | 2 +- 4 files changed, 190 insertions(+), 28 deletions(-) diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index fdfb4560..b2201d6f 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -2967,37 +2967,45 @@ Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase { Inbound.TunnelSettings = class extends Inbound.Settings { constructor( protocol, - address, - port, + rewriteAddress, + rewritePort, portMap = [], - network = 'tcp,udp', + allowedNetwork = 'tcp,udp', followRedirect = false ) { super(protocol); - this.address = address; - this.port = port; + this.rewriteAddress = rewriteAddress; + this.rewritePort = rewritePort; this.portMap = portMap; - this.network = network; + this.allowedNetwork = allowedNetwork; this.followRedirect = followRedirect; } + addPortMap(port = '', target = '') { + this.portMap.push({ name: port, value: target }); + } + + removePortMap(index) { + this.portMap.splice(index, 1); + } + static fromJson(json = {}) { return new Inbound.TunnelSettings( Protocols.TUNNEL, - json.address, - json.port, + json.rewriteAddress, + json.rewritePort, XrayCommonClass.toHeaders(json.portMap), - json.network, + json.allowedNetwork, json.followRedirect, ); } toJson() { return { - address: this.address, - port: this.port, + rewriteAddress: this.rewriteAddress, + rewritePort: this.rewritePort, portMap: XrayCommonClass.toV2Headers(this.portMap, false), - network: this.network, + allowedNetwork: this.allowedNetwork, followRedirect: this.followRedirect, }; } diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index 2c814dc2..4d09f63c 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -679,10 +679,7 @@ watch( - - + - - + + 0 ? props.pageSize : rows.length || 1; @@ -388,13 +396,16 @@ function showQrCodeMenu(dbInbound) {
- +
#{{ record.id }} {{ record.remark }}
+ + + @@ -452,69 +463,6 @@ function showQrCodeMenu(dbInbound) {
- -
-
- {{ t('pages.inbounds.protocol') }} - {{ record.protocol }} - -
-
- {{ t('pages.inbounds.port') }} - {{ record.port }} -
-
- {{ t('pages.inbounds.node') }} - - {{ t('pages.inbounds.localPanel') }} - - - {{ nodesById.get(record.nodeId).name }} - - #{{ record.nodeId }} -
-
- {{ t('pages.inbounds.traffic') }} - - {{ SizeFormatter.sizeFormat(record.up + record.down) }} / - - - -
-
- {{ t('pages.inbounds.allTimeTraffic') }} - {{ SizeFormatter.sizeFormat(record.allTime || 0) }} -
-
- {{ t('clients') }} - {{ clientCount[record.id].clients }} - - {{ clientCount[record.id].online.length }} {{ t('online') }} - - - {{ clientCount[record.id].depleted.length }} {{ t('depleted') }} - - - {{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }} - -
-
- {{ t('pages.inbounds.expireDate') }} - - {{ IntlUtil.formatRelativeTime(record.expiryTime) }} - - - - -
-
-
+ + +
+
+ {{ t('pages.inbounds.protocol') }} + {{ statsRecord.protocol }} + +
+
+ {{ t('pages.inbounds.port') }} + {{ statsRecord.port }} +
+
+ {{ t('pages.inbounds.node') }} + + {{ t('pages.inbounds.localPanel') }} + + + {{ nodesById.get(statsRecord.nodeId).name }} + + #{{ statsRecord.nodeId }} +
+
+ {{ t('pages.inbounds.traffic') }} + + {{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} / + + + +
+
+ {{ t('pages.inbounds.allTimeTraffic') }} + {{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }} +
+
+ {{ t('clients') }} + {{ clientCount[statsRecord.id].clients }} + + {{ clientCount[statsRecord.id].online.length }} {{ t('online') }} + + + {{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }} + + + {{ clientCount[statsRecord.id].expiring.length }} {{ t('depletingSoon') }} + +
+
+ {{ t('pages.inbounds.expireDate') }} + + {{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }} + + + + +
+
+
+ Date: Wed, 13 May 2026 23:14:56 +0200 Subject: [PATCH 05/14] feat(nodes): mobile card list, info modal, and tighter summary layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeList now branches on isMobile: a vertical card list mirrors the inbound mobile redesign — status dot + name + an Info icon that opens an a-modal with the full per-node stats (address, status, CPU/mem, xray version, uptime, latency, last heartbeat). The card head expands to surface NodeHistoryPanel inline (parity with the desktop expandable row), and the more-dropdown carries probe/edit/delete. NodesPage also gets two layout fixes: an 8px vertical gutter between the summary card and the node list on mobile (was 0), and a 2x2 grid for the four summary statistics on phones via :xs="12" plus a 16px inner vertical gutter, so Total/Online/Offline/Avg Latency no longer crowd each other. --- frontend/src/pages/nodes/NodeList.vue | 224 ++++++++++++++++++++++++- frontend/src/pages/nodes/NodesPage.vue | 12 +- 2 files changed, 229 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/nodes/NodeList.vue b/frontend/src/pages/nodes/NodeList.vue index bd84ecec..73dc6236 100644 --- a/frontend/src/pages/nodes/NodeList.vue +++ b/frontend/src/pages/nodes/NodeList.vue @@ -9,6 +9,9 @@ import { ExclamationCircleOutlined, EyeOutlined, EyeInvisibleOutlined, + InfoCircleOutlined, + MoreOutlined, + RightOutlined, } from '@ant-design/icons-vue'; import NodeHistoryPanel from './NodeHistoryPanel.vue'; @@ -72,6 +75,25 @@ function formatPct(p) { if (typeof p !== 'number' || isNaN(p)) return '-'; return `${p.toFixed(1)}%`; } + +const statsNode = ref(null); +function openStats(node) { + statsNode.value = node; +} +function closeStats() { + statsNode.value = null; +} + +const expandedIds = ref(new Set()); +function toggleExpanded(id) { + const next = new Set(expandedIds.value); + if (next.has(id)) next.delete(id); + else next.add(id); + expandedIds.value = next; +} +function isExpanded(id) { + return expandedIds.value.has(id); +} - + @@ -1032,11 +1032,6 @@ function regenerateWgKeys() { opacity: 0.85; } -.json-editor { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 12px; -} - /* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as * inline-block, but inside a narrow form wrapper they can wrap * inconsistently. Force a clean horizontal row with even gaps. */ diff --git a/frontend/src/pages/xray/XrayPage.vue b/frontend/src/pages/xray/XrayPage.vue index ba04c59f..e80a4cbb 100644 --- a/frontend/src/pages/xray/XrayPage.vue +++ b/frontend/src/pages/xray/XrayPage.vue @@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue'; import DnsTab from './DnsTab.vue'; import WarpModal from './WarpModal.vue'; import NordModal from './NordModal.vue'; +import JsonEditor from '@/components/JsonEditor.vue'; import { useXraySetting } from './useXraySetting.js'; import { useWebSocket } from '@/composables/useWebSocket.js'; @@ -376,8 +377,7 @@ onBeforeUnmount(() => { {{ t('pages.xray.Outbounds') }} {{ t('pages.xray.Routings') }} - + @@ -464,11 +464,6 @@ onBeforeUnmount(() => { margin: 0; } -.json-editor { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 12px; -} - .icons-only :deep(.ant-tabs-nav) { margin-bottom: 8px; } From 2551a673c337b5882191424711c2227ea6987a45 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 14 May 2026 01:31:49 +0200 Subject: [PATCH 08/14] fix(inbounds): refresh client rows live over websocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs combined to leave per-client traffic / remained / all-time columns stuck at stale numbers while only the inbound-level row and the online badge refreshed: 1. Backend (xray + node sync traffic jobs) only included the per-client array in the client_stats broadcast when activeEmails / touched was non-empty. Cycles with no client deltas — or any node sync that failed to fetch a snapshot — shipped only the inbound summary, so the frontend had nothing to merge for clients. Replaced both code paths with a single GetAllClientTraffics() snapshot per cycle; the broadcast now always carries the full client list. 2. Frontend mutated dbInbound.clientStats[i] in place. DBInbound is a plain class instance (not wrapped in reactive()), so Vue could not see the field-level changes and ClientRowTable's statsMap computed stayed cached forever. Added a statsVersion tick bumped on every merge and read inside statsMap so the computed re-evaluates and the template pulls fresh up/down/allTime/expiryTime each push. Removed the now-dead emailSet helper from node_traffic_sync_job and the activeEmails filter from xray_traffic_job. --- .../src/pages/inbounds/ClientRowTable.vue | 5 ++ frontend/src/pages/inbounds/InboundList.vue | 3 + frontend/src/pages/inbounds/InboundsPage.vue | 2 + frontend/src/pages/inbounds/useInbounds.js | 13 ++++- web/job/node_traffic_sync_job.go | 57 ++----------------- web/job/xray_traffic_job.go | 40 +++---------- web/service/inbound.go | 14 +++++ 7 files changed, 49 insertions(+), 85 deletions(-) diff --git a/frontend/src/pages/inbounds/ClientRowTable.vue b/frontend/src/pages/inbounds/ClientRowTable.vue index 8a942bff..6ed33119 100644 --- a/frontend/src/pages/inbounds/ClientRowTable.vue +++ b/frontend/src/pages/inbounds/ClientRowTable.vue @@ -33,6 +33,7 @@ const props = defineProps({ isDarkTheme: { type: Boolean, default: false }, pageSize: { type: Number, default: 0 }, totalClientCount: { type: Number, default: 0 }, + statsVersion: { type: Number, default: 0 }, }); const emit = defineEmits([ @@ -63,7 +64,11 @@ watch([clients, () => props.pageSize], () => { }); // === Per-client stats lookup ======================================= +// statsVersion bumps on every ws merge so this computed re-evaluates +// (DBInbound isn't reactive — the in-place stat mutations alone don't +// trigger Vue's tracking). const statsMap = computed(() => { + void props.statsVersion; const m = new Map(); for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs); return m; diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index 3671a520..13e64f03 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -50,6 +50,7 @@ const props = defineProps({ // inbound row can render its node name without an extra fetch. nodesById: { type: Map, default: () => new Map() }, hasActiveNode: { type: Boolean, default: false }, + statsVersion: { type: Number, default: 0 }, }); const emit = defineEmits([ @@ -468,6 +469,7 @@ function showQrCodeMenu(dbInbound) { 0 { - if stats, err := j.inboundService.GetActiveClientTraffics(emails); err != nil { - logger.Warning("node traffic sync: get client traffics for websocket failed:", err) - } else if len(stats) > 0 { - clientStats["clients"] = stats - } + if stats, err := j.inboundService.GetAllClientTraffics(); err != nil { + logger.Warning("node traffic sync: get all client traffics for websocket failed:", err) + } else if len(stats) > 0 { + clientStats["clients"] = stats } if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil { logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err) @@ -156,7 +123,7 @@ func (j *NodeTrafficSyncJob) Run() { } } -func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touched *emailSet) { +func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) { ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout) defer cancel() @@ -179,16 +146,4 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touche if changed { j.structural.set() } - for _, ib := range snap.Inbounds { - if ib == nil { - continue - } - emails := make([]string, 0, len(ib.ClientStats)) - for _, cs := range ib.ClientStats { - if cs.Email != "" { - emails = append(emails, cs.Email) - } - } - touched.addAll(emails) - } } diff --git a/web/job/xray_traffic_job.go b/web/job/xray_traffic_job.go index b434936f..96539986 100644 --- a/web/job/xray_traffic_job.go +++ b/web/job/xray_traffic_job.go @@ -95,18 +95,16 @@ func (j *XrayTrafficJob) Run() { "lastOnlineMap": lastOnlineMap, }) - // Compact delta payload: per-client absolute counters for clients active - // this cycle, plus inbound-level absolute totals. Frontend applies both - // in-place — typical payload ~10–50KB even for 10k+ client deployments. - // Replaces the old full-inbound-list broadcast that hit WS size limits - // (5–10MB) and forced the frontend into a REST refetch. + // Full snapshot every cycle: absolute per-client counters and inbound + // totals. Frontend overwrites both in place. The previous delta path + // (activeEmails -> GetActiveClientTraffics) silently omitted the + // clients array whenever nobody moved bytes in the cycle, leaving the + // client rows in the UI stuck at stale traffic/remained/all-time. clientStatsPayload := map[string]any{} - if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 { - if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil { - logger.Warning("get active client traffics for websocket failed:", err) - } else if len(stats) > 0 { - clientStatsPayload["clients"] = stats - } + if stats, err := j.inboundService.GetAllClientTraffics(); err != nil { + logger.Warning("get all client traffics for websocket failed:", err) + } else if len(stats) > 0 { + clientStatsPayload["clients"] = stats } if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil { logger.Warning("get inbounds traffic summary for websocket failed:", err) @@ -126,26 +124,6 @@ func (j *XrayTrafficJob) Run() { } } -// activeEmails returns the set of client emails that had non-zero traffic in -// the current collection window. Idle clients are skipped — no need to push -// their (unchanged) counters to the frontend. -func activeEmails(clientTraffics []*xray.ClientTraffic) []string { - if len(clientTraffics) == 0 { - return nil - } - emails := make([]string, 0, len(clientTraffics)) - for _, ct := range clientTraffics { - if ct == nil || ct.Email == "" { - continue - } - if ct.Up == 0 && ct.Down == 0 { - continue - } - emails = append(emails, ct.Email) - } - return emails -} - func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) { informURL, err := j.settingService.GetExternalTrafficInformURI() if err != nil { diff --git a/web/service/inbound.go b/web/service/inbound.go index e0c80d69..00cb58fe 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -3322,6 +3322,20 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien return traffics, nil } +// GetAllClientTraffics returns the full set of client_traffics rows so the +// websocket broadcasters can ship a complete snapshot every cycle. The old +// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped +// the per-client section whenever no client moved bytes in the cycle or a +// node sync failed, leaving client rows in the UI stuck at stale numbers. +func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) { + db := database.GetDB() + var traffics []*xray.ClientTraffic + if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil { + return nil, err + } + return traffics, nil +} + type InboundTrafficSummary struct { Id int `json:"id"` Up int64 `json:"up"` From 26accfd8f761a68b636403d01bf6d226d6fa66a1 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 14 May 2026 01:45:00 +0200 Subject: [PATCH 09/14] fix(qr): lock QR code modules to black-on-white across all themes AntD's defaults the module color to the active theme's text token. Under the dark and ultra-dark themes that text is a light gray, so the QR rendered low-contrast on the white canvas background and phones could not lock onto it. Pinned color="#000000" and bg-color="#ffffff" on every usage (share links in QrPanel, 2FA enrollment in TwoFactorModal, sub/json/clash codes on SubPage) so the contrast stays high regardless of panel theme. --- frontend/src/pages/inbounds/QrPanel.vue | 2 +- frontend/src/pages/settings/TwoFactorModal.vue | 2 +- frontend/src/pages/sub/SubPage.vue | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/inbounds/QrPanel.vue b/frontend/src/pages/inbounds/QrPanel.vue index c357d5c1..fc5cbb61 100644 --- a/frontend/src/pages/inbounds/QrPanel.vue +++ b/frontend/src/pages/inbounds/QrPanel.vue @@ -47,7 +47,7 @@ function download() {
+ color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
diff --git a/frontend/src/pages/settings/TwoFactorModal.vue b/frontend/src/pages/settings/TwoFactorModal.vue index d0b5819a..944259ea 100644 --- a/frontend/src/pages/settings/TwoFactorModal.vue +++ b/frontend/src/pages/settings/TwoFactorModal.vue @@ -82,7 +82,7 @@ async function copyToken() {

{{ t('pages.settings.security.twoFactorModalFirstStep') }}

+ color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" /> {{ token }}
diff --git a/frontend/src/pages/sub/SubPage.vue b/frontend/src/pages/sub/SubPage.vue index 33df819a..01c765ad 100644 --- a/frontend/src/pages/sub/SubPage.vue +++ b/frontend/src/pages/sub/SubPage.vue @@ -204,7 +204,7 @@ const themeClass = computed(() => ({
{{ t('pages.settings.subSettings') }} + color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subUrl)" />
@@ -213,14 +213,14 @@ const themeClass = computed(() => ({ {{ t('pages.settings.subSettings') }} JSON + color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subJsonUrl)" />
Clash / Mihomo + color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subClashUrl)" />
From 194de8869e43a6c4b29c87808aae07be78698e84 Mon Sep 17 00:00:00 2001 From: Black Date: Thu, 14 May 2026 04:55:00 +0500 Subject: [PATCH 10/14] feat(panel): add 'Edit' button to tables and enhance layout (#4355) - Move 'Edit' button from dropdown to the table since it's the most used action. Only for desktop. - Increase column widths for action keys in Inbounds, Balancers, Outbounds and Routing tables. - Slightly enhance layout for consistency. --- frontend/src/pages/inbounds/InboundList.vue | 120 +++++++++++--------- frontend/src/pages/xray/BalancersTab.vue | 68 +++++++---- frontend/src/pages/xray/OutboundsTab.vue | 81 ++++++++----- frontend/src/pages/xray/RoutingTab.vue | 61 ++++++---- frontend/src/pages/xray/XrayPage.vue | 3 +- 5 files changed, 208 insertions(+), 125 deletions(-) diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index 13e64f03..db0cfa05 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -230,7 +230,7 @@ const hasAnyRemark = computed(() => const desktopColumns = computed(() => { const cols = [ sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'), - { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 }, + { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 }, sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'), ]; if (hasAnyRemark.value) { @@ -571,59 +571,68 @@ function showQrCodeMenu(dbInbound) {