diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 4a6a66765..e72aaa5e5 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -100,7 +100,7 @@ export default function InboundsPage() { const [messageApi, messageContextHolder] = message.useMessage(); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); - const { nodes: nodesList } = useNodesQuery(); + const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery(); const nodesById = useMemo(() => { const map = new Map['nodes'][number]>(); for (const n of nodesList || []) map.set(n.id, n); @@ -647,6 +647,7 @@ export default function InboundsPage() { dbInbound={formDbInbound} dbInbounds={dbInbounds} availableNodes={nodesList} + availableNodesFetched={nodesFetched} /> diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 7b1fc1acf..9600cbe77 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -138,6 +138,7 @@ interface InboundFormModalProps { dbInbound: DBInbound | null; dbInbounds: DBInbound[]; availableNodes?: NodeRecord[]; + availableNodesFetched?: boolean; } function buildAddModeValues(): InboundFormValues { @@ -167,6 +168,7 @@ export default function InboundFormModal({ dbInbound, dbInbounds, availableNodes, + availableNodesFetched = true, }: InboundFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); @@ -373,14 +375,22 @@ export default function InboundFormModal({ // offered (no node, or a protocol that can't deploy to one) fall back to // `listen`, which yields the same link for a local inbound. Mirrors how the // protocol reset drops a nodeId that no longer applies. + // Only downgrade once the inputs this decision depends on are settled, so a + // persisted `node` strategy is never clobbered by transient mount state (#5375): + // - `availableNodesFetched`: an empty `availableNodes` during the async + // /nodes/list fetch is a placeholder, not "no nodes". + // - `protocol`: `Form.useWatch('protocol')` is briefly empty on the first + // edit render before initialValues apply, which would momentarily make the + // node option look unavailable. useEffect(() => { if (!open) return; + if (!availableNodesFetched || !protocol) return; const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined; if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') { form.setFieldValue('shareAddrStrategy', 'listen'); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, nodeShareOptionAvailable, shareAddrStrategy]); + }, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]); // Why: protocol picker reset cascades through the form — clearing the // settings DU branch and dropping a nodeId that no longer applies. The diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 9e41ac7aa..89affdc39 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { screen, act } from '@testing-library/react'; +import { screen, act, render, cleanup } from '@testing-library/react'; import InboundFormModal from '@/pages/inbounds/form/InboundFormModal'; import { DBInbound } from '@/models/dbinbound'; +import { ThemeProvider } from '@/hooks/useTheme'; import { renderWithProviders, fieldLabels, @@ -90,4 +91,54 @@ describe('InboundFormModal', () => { const shareAddrInput = await screen.findByDisplayValue('edge.example.test'); expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test'); }); + + it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => { + const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never; + const buildInbound = () => new DBInbound({ + id: 1, + port: 23456, + listen: '', + protocol: 'vless', + remark: 'noded', + enable: true, + settings: { clients: [] }, + streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} }, + sniffing: { enabled: false }, + nodeId: 1, + shareAddrStrategy: 'node', + }); + const flush = async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); }; + const strategyItem = (title: string) => + document.querySelector(`.ant-select-content[title="${title}"]`); + const modal = (nodes: never[], fetched: boolean) => ( + + {}} + onSaved={() => {}} + /> + + ); + + // Baseline: nodes already loaded, so the node option is offered and selected. + render(modal([node], true)); + await flush(); + expect(strategyItem('Node address')).toBeTruthy(); + cleanup(); + + // Race: the modal mounts before /nodes/list resolves (empty placeholder), + // then nodes arrive. The persisted 'node' strategy must survive the gap and + // stay selected once the option reappears — not silently revert to listen. + const { rerender } = render(modal([], false)); + await flush(); + rerender(modal([node], true)); + await flush(); + expect(strategyItem('Node address')).toBeTruthy(); + expect(strategyItem('Inbound listen')).toBeFalsy(); + }); }); diff --git a/internal/database/index_tags_test.go b/internal/database/index_tags_test.go index 03396119c..6dd7ce73c 100644 --- a/internal/database/index_tags_test.go +++ b/internal/database/index_tags_test.go @@ -21,7 +21,7 @@ func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&model.ClientRecord{}, &xray.ClientTraffic{}); err != nil { + if err := db.AutoMigrate(&model.ClientRecord{}, &xray.ClientTraffic{}, &model.ClientGlobalTraffic{}); err != nil { t.Fatalf("automigrate: %v", err) } @@ -32,6 +32,7 @@ func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) { {&model.ClientRecord{}, "idx_client_record_group"}, {&xray.ClientTraffic{}, "idx_client_traffics_inbound"}, {&xray.ClientTraffic{}, "idx_client_traffics_renew"}, + {&model.ClientGlobalTraffic{}, "idx_client_global_email"}, } for _, c := range cases { if !db.Migrator().HasIndex(c.model, c.index) { diff --git a/internal/database/model/client_global_traffic.go b/internal/database/model/client_global_traffic.go index 407051e46..b260f79e0 100644 --- a/internal/database/model/client_global_traffic.go +++ b/internal/database/model/client_global_traffic.go @@ -13,7 +13,7 @@ package model type ClientGlobalTraffic struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` MasterGuid string `json:"masterGuid" gorm:"uniqueIndex:idx_master_email,priority:1;not null"` - Email string `json:"email" gorm:"uniqueIndex:idx_master_email,priority:2;not null"` + Email string `json:"email" gorm:"uniqueIndex:idx_master_email,priority:2;index:idx_client_global_email;not null"` Up int64 `json:"up"` Down int64 `json:"down"` UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` diff --git a/internal/sub/build_urls_test.go b/internal/sub/build_urls_test.go index 42517b20b..f29400aa9 100644 --- a/internal/sub/build_urls_test.go +++ b/internal/sub/build_urls_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func initSubDB(t *testing.T) { @@ -60,6 +61,30 @@ func TestBuildURLs_UsesSubscriberDomain(t *testing.T) { } } +// A local wildcard inbound (no node, no custom share address, blank/0.0.0.0 +// listen) must not advertise the raw request host when it carries a client IP +// that leaked in behind NAT/proxy. The admin's configured panel host wins for +// this last-resort fallback; without a configured host the request host stands. +func TestResolveInboundAddress_PrefersConfiguredHostOverClientIP(t *testing.T) { + initSubDB(t) + local := &model.Inbound{Listen: "", ShareAddrStrategy: "node"} + + s := &SubService{} + s.PrepareForRequest("192.168.1.50") // a client LAN IP that reached the panel + if got := s.resolveInboundAddress(local); got != "192.168.1.50" { + t.Fatalf("with no configured host the request host stands, got %q", got) + } + + if err := database.GetDB().Create(&model.Setting{Key: "subDomain", Value: "panel.example.com"}).Error; err != nil { + t.Fatalf("set subDomain: %v", err) + } + s2 := &SubService{} + s2.PrepareForRequest("192.168.1.50") + if got := s2.resolveInboundAddress(local); got != "panel.example.com" { + t.Fatalf("configured host must win over the leaked client IP, got %q", got) + } +} + func TestBuildURLs_EmptySubId(t *testing.T) { initSubDB(t) s := &SubService{} diff --git a/internal/sub/service.go b/internal/sub/service.go index b34a161b8..1e16356b1 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -929,10 +929,13 @@ func (s *SubService) loadNodes() { // - "node" (default, and any unknown value): the node's address for // node-managed inbounds, then a routable Listen — the pre-strategy order. // -// Every chain ends at the subscriber's request host (s.address). A -// loopback/wildcard bind or a unix-domain-socket listen is a server-side -// detail and is never advertised; External Proxy still overrides everything -// upstream of this call. +// Every chain ends at the admin's configured public host (Sub/Web domain) and +// then the subscriber's request host (s.address). Preferring the configured +// host over the request host for this last resort keeps a wildcard local inbound +// from advertising a bogus client IP that leaked into the request Host header +// behind NAT/proxy/CDN (#5425). A loopback/wildcard bind or a unix-domain-socket +// listen is a server-side detail and is never advertised; External Proxy still +// overrides everything upstream of this call. func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string { var nodeAddr string if inbound.NodeID != nil && s.nodesByID != nil { @@ -957,6 +960,9 @@ func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string { return c } } + if d := s.configuredPublicHost(); d != "" { + return d + } return s.address } diff --git a/internal/web/service/client_traffic.go b/internal/web/service/client_traffic.go index ee9bc3568..43ff1304a 100644 --- a/internal/web/service/client_traffic.go +++ b/internal/web/service/client_traffic.go @@ -101,7 +101,15 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st } affected += int(res.RowsAffected) } - return clearGlobalTraffic(tx, cleanEmails...) + if err := clearGlobalTraffic(tx, cleanEmails...); err != nil { + return err + } + for _, batch := range chunkStrings(cleanEmails, sqlInChunk) { + if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil { + return err + } + } + return nil }) }) if err != nil { @@ -154,6 +162,12 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error { return err } + for _, batch := range chunkStrings(resetEmails, sqlInChunk) { + if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil { + return err + } + } + inboundWhereText := "id " if id == -1 { inboundWhereText += " > ?" diff --git a/internal/web/service/global_traffic_test.go b/internal/web/service/global_traffic_test.go index b46c2a140..3ffc7b2b2 100644 --- a/internal/web/service/global_traffic_test.go +++ b/internal/web/service/global_traffic_test.go @@ -64,6 +64,32 @@ func TestAcceptGlobalTraffic_OverwriteAndMultiMaster(t *testing.T) { } } +func TestDepletedCond_ProbeGuard(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + + // No global rows: the cross-panel EXISTS branch is skipped (#5392), but a + // client over its local quota is still disabled. + if got := depletedCond(db); got != depletedClientsCondLocal { + t.Fatalf("empty globals must use the local-only predicate") + } + seedClientRow(t, "local-cap", 1, 600, 600, 1000) + if _, count, _, err := svc.disableInvalidClients(db); err != nil { + t.Fatalf("disableInvalidClients: %v", err) + } else if count != 1 { + t.Fatalf("local over-quota client must be disabled, disabled %d", count) + } + + // Once a master pushes a global row, the full predicate is used so combined + // quota is enforced. + if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil { + t.Fatalf("AcceptGlobalTraffic: %v", err) + } + if got := depletedCond(db); got != depletedClientsCond { + t.Fatalf("with globals present the cross-panel predicate must be used") + } +} + func TestGlobalUsage_DisablesClient(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index 660abcbee..721defba8 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -60,13 +60,34 @@ const depletedClientsCond = `((total > 0 AND up + down >= total) WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total )))` +// depletedClientsCondLocal is depletedClientsCond without the cross-panel +// client_global_traffics check. The EXISTS branch is a correlated subquery that +// turns every traffic poll into a full client_traffics scan; on a panel no +// master pushes to (the common case) client_global_traffics is empty, so the +// branch can never match and is pure CPU cost (#5392). +const depletedClientsCondLocal = `((total > 0 AND up + down >= total) + OR (expiry_time > 0 AND expiry_time <= ?))` + +// depletedCond returns the local-only predicate unless this panel actually +// holds global-traffic rows, in which case the cross-panel EXISTS check is +// needed to enforce combined quota. Both variants take the same single +// expiry_time placeholder, so callers pass identical args either way. +func depletedCond(tx *gorm.DB) string { + var probe int64 + if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 { + return depletedClientsCond + } + return depletedClientsCondLocal +} + func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) { now := time.Now().Unix() * 1000 needRestart := false + cond := depletedCond(tx) var depletedRows []xray.ClientTraffic err := tx.Model(xray.ClientTraffic{}). - Where(depletedClientsCond+" AND enable = ?", now, true). + Where(cond+" AND enable = ?", now, true). Find(&depletedRows).Error if err != nil { return false, 0, nil, err @@ -142,7 +163,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, } result := tx.Model(xray.ClientTraffic{}). - Where(depletedClientsCond+" AND enable = ?", now, true). + Where(cond+" AND enable = ?", now, true). Update("enable", false) err = result.Error count := result.RowsAffected diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 9d1becef4..0836203ad 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -571,10 +571,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi var deltaUp, deltaDown int64 if seen { if deltaUp = canon.Up - base.Up; deltaUp < 0 { - deltaUp = canon.Up + deltaUp = 0 } if deltaDown = canon.Down - base.Down; deltaDown < 0 { - deltaDown = canon.Down + deltaDown = 0 } } diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 261a2596a..d99eefaf1 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -506,9 +506,12 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { if err := clearGlobalTraffic(db, clientEmail); err != nil { return err } - return db.Model(xray.ClientTraffic{}). + if err := db.Model(xray.ClientTraffic{}). Where("email = ?", clientEmail). - Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error + Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil { + return err + } + return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error }) } @@ -602,6 +605,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b if err := clearGlobalTraffic(db, clientEmail); err != nil { return false, err } + if err := db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error; err != nil { + return false, err + } now := time.Now().UnixMilli() _ = db.Model(model.Inbound{}). diff --git a/internal/web/service/node_client_traffic_sum_test.go b/internal/web/service/node_client_traffic_sum_test.go index 0c5dc0b8b..0676a1fcf 100644 --- a/internal/web/service/node_client_traffic_sum_test.go +++ b/internal/web/service/node_client_traffic_sum_test.go @@ -169,7 +169,7 @@ func TestGhostData_NoPhantomTraffic(t *testing.T) { assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts") } -func TestNodeCounterReset_Clamped(t *testing.T) { +func TestNodeCounterReset_NoReAdd(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001) svc := &InboundService{} @@ -180,13 +180,19 @@ func TestNodeCounterReset_Clamped(t *testing.T) { syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset") - // Counter resets to 50 (Xray restart). delta=50-950=-900 → clamped → adds 50. + // Node reboot drops the counter to 50. delta=50-950=-900 is a counter reset, + // not new traffic: add 0 and rebaseline to 50, never re-add the node's full + // cumulative counter onto the master total (#5456). syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true}) ct := readTraffic(t, db, email) if ct.Up < 0 || ct.Down < 0 { t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down) } - assertUpDown(t, ct, 100, 100, "after node counter reset (clamped)") + assertUpDown(t, ct, 50, 50, "after node counter reset: rebaselined, not re-added") + + // Post-reset accrual resumes from the new baseline: 80-50=30. + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 80, Down: 80, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 80, 80, "post-reset delta accrues from rebaselined counter") } func TestCentralReset_NoReAdd(t *testing.T) { @@ -212,6 +218,43 @@ func TestCentralReset_NoReAdd(t *testing.T) { assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue") } +// A real reset (ResetClientTrafficByEmail) must clear the per-node baseline so +// the node's pre-reset cumulative — including traffic it counted but had not yet +// synced — cannot leak back onto the master after the reset (#5476, #5390). +func TestCentralResetClearsNodeBaseline_NoLeak(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-in", 41001) + StartTrafficWriter() + svc := &InboundService{} + + const email = "reset-revert" + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 200, 200, "before reset") + + if err := svc.ResetClientTrafficByEmail(email); err != nil { + t.Fatalf("ResetClientTrafficByEmail: %v", err) + } + assertUpDown(t, readTraffic(t, db, email), 0, 0, "right after reset") + + var baselines int64 + if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&baselines).Error; err != nil { + t.Fatalf("count baselines: %v", err) + } + if baselines != 0 { + t.Fatalf("reset must clear node baseline rows, found %d", baselines) + } + + // Node still reports its pre-reset cumulative (340 > last synced 300: usage it + // had not synced before the reset). It must not revert the reset. + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 340, Down: 340, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 0, 0, "stale node counter must not revert reset") + + // Genuine post-reset usage accrues from the rebaselined counter: 370-340=30. + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 370, Down: 370, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 30, 30, "post-reset usage accrues") +} + func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) { db := initTrafficTestDB(t) createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared") diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 3c465f795..9dbda6785 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -6,7 +6,7 @@ "cancel": "إلغاء", "close": "إغلاق", "save": "حفظ", - "logout": "تسجيل خروج", + "logout": "تسجيل خروج <3", "create": "إنشاء", "add": "إضافة", "remove": "إزالة", @@ -2054,4 +2054,4 @@ "statusDown": "غير متصل", "statusUp": "متصل" } -} +} \ No newline at end of file diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 811478dbb..315ecb782 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -6,7 +6,7 @@ "cancel": "Cancel", "close": "Close", "save": "Save", - "logout": "Log Out", + "logout": "Log Out <3", "create": "Create", "add": "Add", "remove": "Remove", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index cec557682..e8617204f 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -6,7 +6,7 @@ "cancel": "Cancelar", "close": "Cerrar", "save": "Guardar", - "logout": "Cerrar Sesión", + "logout": "Cerrar Sesión <3", "create": "Crear", "add": "Añadir", "remove": "Quitar", @@ -2054,4 +2054,4 @@ "statusDown": "CAÍDO", "statusUp": "ACTIVO" } -} +} \ No newline at end of file diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 9592ab55c..b62bf4173 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -6,7 +6,7 @@ "cancel": "انصراف", "close": "بستن", "save": "ذخیره", - "logout": "خروج", + "logout": "خروج <3", "create": "ایجاد", "add": "افزودن", "remove": "حذف", @@ -2054,4 +2054,4 @@ "statusDown": "قطع", "statusUp": "وصل" } -} +} \ No newline at end of file diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 21df10e12..6a7a3705b 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -6,7 +6,7 @@ "cancel": "Batal", "close": "Tutup", "save": "Simpan", - "logout": "Keluar", + "logout": "Keluar <3", "create": "Buat", "add": "Tambah", "remove": "Hapus", @@ -2054,4 +2054,4 @@ "statusDown": "MATI", "statusUp": "AKTIF" } -} +} \ No newline at end of file diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 2b19b10de..8bf5432d5 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -6,7 +6,7 @@ "cancel": "キャンセル", "close": "閉じる", "save": "保存", - "logout": "ログアウト", + "logout": "ログアウト <3", "create": "作成", "add": "追加", "remove": "削除", @@ -2054,4 +2054,4 @@ "statusDown": "ダウン", "statusUp": "アップ" } -} +} \ No newline at end of file diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 801f25652..4c5ef8fb6 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -6,7 +6,7 @@ "cancel": "Cancelar", "close": "Fechar", "save": "Salvar", - "logout": "Sair", + "logout": "Sair <3", "create": "Criar", "add": "Adicionar", "remove": "Remover", @@ -2054,4 +2054,4 @@ "statusDown": "INATIVO", "statusUp": "ATIVO" } -} +} \ No newline at end of file diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index a00a1f094..79a4ec8b3 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -6,7 +6,7 @@ "cancel": "Отмена", "close": "Закрыть", "save": "Сохранить", - "logout": "Выход", + "logout": "Выход <3", "create": "Создать", "add": "Добавить", "remove": "Удалить", @@ -2054,4 +2054,4 @@ "statusDown": "НЕДОСТУПЕН", "statusUp": "РАБОТАЕТ" } -} +} \ No newline at end of file diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 16a9345d4..494164165 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -6,7 +6,7 @@ "cancel": "İptal", "close": "Kapat", "save": "Kaydet", - "logout": "Çıkış Yap", + "logout": "Çıkış Yap <3", "create": "Oluştur", "add": "Ekle", "remove": "Kaldır", @@ -2054,4 +2054,4 @@ "statusDown": "ÇEVRİMDIŞI", "statusUp": "ÇEVRİMİÇİ" } -} +} \ No newline at end of file diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 9e7412cd6..3b4784fe6 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -6,7 +6,7 @@ "cancel": "Скасувати", "close": "Закрити", "save": "Зберегти", - "logout": "Вийти", + "logout": "Вийти <3", "create": "Створити", "add": "Додати", "remove": "Видалити", @@ -2054,4 +2054,4 @@ "statusDown": "НЕДОСТУПНО", "statusUp": "ДОСТУПНО" } -} +} \ No newline at end of file diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 9f202252f..f250e8119 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -6,7 +6,7 @@ "cancel": "Hủy bỏ", "close": "Đóng", "save": "Lưu", - "logout": "Đăng xuất", + "logout": "Đăng xuất <3", "create": "Tạo", "add": "Thêm", "remove": "Xóa", @@ -2054,4 +2054,4 @@ "statusDown": "NGỪNG HOẠT ĐỘNG", "statusUp": "HOẠT ĐỘNG" } -} +} \ No newline at end of file diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index a8bafe032..0c781eb45 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -6,7 +6,7 @@ "cancel": "取消", "close": "关闭", "save": "保存", - "logout": "登出", + "logout": "登出 <3", "create": "创建", "add": "添加", "remove": "移除", @@ -2054,4 +2054,4 @@ "statusDown": "断开", "statusUp": "恢复" } -} +} \ No newline at end of file diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 82940df05..3a33196a6 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -6,7 +6,7 @@ "cancel": "取消", "close": "關閉", "save": "儲存", - "logout": "登出", + "logout": "登出 <3", "create": "建立", "add": "新增", "remove": "移除", @@ -2054,4 +2054,4 @@ "statusDown": "中斷", "statusUp": "恢復" } -} +} \ No newline at end of file