mirror of
				https://github.com/MHSanaei/3x-ui.git
				synced 2025-10-31 20:32:52 +00:00 
			
		
		
		
	feat: add "Last Online" column to client list and modal (Closes #3402) (#3405)
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Release 3X-UI / build (386) (push) Has been cancelled
				
			
		
			
				
	
				Release 3X-UI / build (amd64) (push) Has been cancelled
				
			
		
			
				
	
				Release 3X-UI / build (arm64) (push) Has been cancelled
				
			
		
			
				
	
				Release 3X-UI / build (armv5) (push) Has been cancelled
				
			
		
			
				
	
				Release 3X-UI / build (armv6) (push) Has been cancelled
				
			
		
			
				
	
				Release 3X-UI / build (armv7) (push) Has been cancelled
				
			
		
			
				
	
				Release 3X-UI / build (s390x) (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Release 3X-UI / build (386) (push) Has been cancelled
				
			Release 3X-UI / build (amd64) (push) Has been cancelled
				
			Release 3X-UI / build (arm64) (push) Has been cancelled
				
			Release 3X-UI / build (armv5) (push) Has been cancelled
				
			Release 3X-UI / build (armv6) (push) Has been cancelled
				
			Release 3X-UI / build (armv7) (push) Has been cancelled
				
			Release 3X-UI / build (s390x) (push) Has been cancelled
				
			* feat: persist client last online and expose API * feat(ui): show client last online in table and info modal * i18n: add “Last Online” across locales * chore: format timestamps as HH:mm:ss
This commit is contained in:
		
							parent
							
								
									664269d513
								
							
						
					
					
						commit
						4a0914cb1e
					
				
					 21 changed files with 71 additions and 7 deletions
				
			
		|  | @ -134,7 +134,7 @@ class DateUtil { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     static formatMillis(millis) { |     static formatMillis(millis) { | ||||||
|         return moment(millis).format('YYYY-M-D H:m:s'); |         return moment(millis).format('YYYY-M-D HH:mm:ss'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     static firstDayOfMonth() { |     static firstDayOfMonth() { | ||||||
|  |  | ||||||
|  | @ -47,6 +47,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { | ||||||
| 		{"POST", "/resetAllClientTraffics/:id", a.inboundController.resetAllClientTraffics}, | 		{"POST", "/resetAllClientTraffics/:id", a.inboundController.resetAllClientTraffics}, | ||||||
| 		{"POST", "/delDepletedClients/:id", a.inboundController.delDepletedClients}, | 		{"POST", "/delDepletedClients/:id", a.inboundController.delDepletedClients}, | ||||||
| 		{"POST", "/onlines", a.inboundController.onlines}, | 		{"POST", "/onlines", a.inboundController.onlines}, | ||||||
|  | 		{"POST", "/lastOnline", a.inboundController.lastOnline}, | ||||||
| 		{"POST", "/updateClientTraffic/:email", a.inboundController.updateClientTraffic}, | 		{"POST", "/updateClientTraffic/:email", a.inboundController.updateClientTraffic}, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -340,6 +340,11 @@ func (a *InboundController) onlines(c *gin.Context) { | ||||||
| 	jsonObj(c, a.inboundService.GetOnlineClients(), nil) | 	jsonObj(c, a.inboundService.GetOnlineClients(), nil) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (a *InboundController) lastOnline(c *gin.Context) { | ||||||
|  | 	data, err := a.inboundService.GetClientsLastOnline() | ||||||
|  | 	jsonObj(c, data, err) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (a *InboundController) updateClientTraffic(c *gin.Context) { | func (a *InboundController) updateClientTraffic(c *gin.Context) { | ||||||
| 	email := c.Param("email") | 	email := c.Param("email") | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -33,12 +33,17 @@ | ||||||
|   <a-switch v-model="client.enable" @change="switchEnableClient(record.id,client)"></a-switch> |   <a-switch v-model="client.enable" @change="switchEnableClient(record.id,client)"></a-switch> | ||||||
| </template> | </template> | ||||||
| <template slot="online" slot-scope="text, client, index"> | <template slot="online" slot-scope="text, client, index"> | ||||||
|   <template v-if="client.enable && isClientOnline(client.email)"> |   <a-popover :overlay-class-name="themeSwitcher.currentTheme"> | ||||||
|     <a-tag color="green">{{ i18n "online" }}</a-tag> |     <template slot="content" > | ||||||
|   </template> |       {{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]] | ||||||
|   <template v-else> |     </template> | ||||||
|     <a-tag>{{ i18n "offline" }}</a-tag> |     <template v-if="client.enable && isClientOnline(client.email)"> | ||||||
|   </template> |       <a-tag color="green">{{ i18n "online" }}</a-tag> | ||||||
|  |     </template> | ||||||
|  |     <template v-else> | ||||||
|  |       <a-tag>{{ i18n "offline" }}</a-tag> | ||||||
|  |     </template> | ||||||
|  |   </a-popover> | ||||||
| </template> | </template> | ||||||
| <template slot="client" slot-scope="text, client"> | <template slot="client" slot-scope="text, client"> | ||||||
|   <a-space direction="horizontal" :size="2"> |   <a-space direction="horizontal" :size="2"> | ||||||
|  |  | ||||||
|  | @ -807,6 +807,7 @@ | ||||||
|             defaultKey: '', |             defaultKey: '', | ||||||
|             clientCount: [], |             clientCount: [], | ||||||
|             onlineClients: [], |             onlineClients: [], | ||||||
|  |             lastOnlineMap: {}, | ||||||
|             isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false, |             isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false, | ||||||
|             refreshing: false, |             refreshing: false, | ||||||
|             refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000, |             refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000, | ||||||
|  | @ -835,6 +836,7 @@ | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|  |                 await this.getLastOnlineMap(); | ||||||
|                 await this.getOnlineUsers(); |                 await this.getOnlineUsers(); | ||||||
|                  |                  | ||||||
|                 this.setInbounds(msg.obj); |                 this.setInbounds(msg.obj); | ||||||
|  | @ -849,6 +851,11 @@ | ||||||
|                 } |                 } | ||||||
|                 this.onlineClients = msg.obj != null ? msg.obj : []; |                 this.onlineClients = msg.obj != null ? msg.obj : []; | ||||||
|             }, |             }, | ||||||
|  |             async getLastOnlineMap() { | ||||||
|  |                 const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline'); | ||||||
|  |                 if (!msg.success || !msg.obj) return; | ||||||
|  |                 this.lastOnlineMap = msg.obj || {} | ||||||
|  |             }, | ||||||
|             async getDefaultSettings() { |             async getDefaultSettings() { | ||||||
|                 const msg = await HttpUtil.post('/panel/setting/defaultSettings'); |                 const msg = await HttpUtil.post('/panel/setting/defaultSettings'); | ||||||
|                 if (!msg.success) { |                 if (!msg.success) { | ||||||
|  | @ -1493,6 +1500,17 @@ | ||||||
|             isClientOnline(email) { |             isClientOnline(email) { | ||||||
|                 return this.onlineClients.includes(email); |                 return this.onlineClients.includes(email); | ||||||
|             }, |             }, | ||||||
|  |             getLastOnline(email) { | ||||||
|  |                 return this.lastOnlineMap[email] || null | ||||||
|  |             }, | ||||||
|  |             formatLastOnline(email) { | ||||||
|  |                 const ts = this.getLastOnline(email) | ||||||
|  |                 if (!ts) return '-' | ||||||
|  |                 if (this.datepicker === 'gregorian') { | ||||||
|  |                     return DateUtil.formatMillis(ts) | ||||||
|  |                 } | ||||||
|  |                 return DateUtil.convertToJalalian(moment(ts)) | ||||||
|  |             }, | ||||||
|             isRemovable(dbInboundId) { |             isRemovable(dbInboundId) { | ||||||
|                 return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1; |                 return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1; | ||||||
|             }, |             }, | ||||||
|  |  | ||||||
|  | @ -217,6 +217,12 @@ | ||||||
|             </template> |             </template> | ||||||
|           </td> |           </td> | ||||||
|         </tr> |         </tr> | ||||||
|  |         <tr> | ||||||
|  |           <td>{{ i18n "lastOnline" }}</td> | ||||||
|  |           <td> | ||||||
|  |             <a-tag>[[ app.formatLastOnline(infoModal.clientSettings && infoModal.clientSettings.email ? infoModal.clientSettings.email : '') ]]</a-tag> | ||||||
|  |           </td> | ||||||
|  |         </tr> | ||||||
|         <tr v-if="infoModal.clientSettings.comment"> |         <tr v-if="infoModal.clientSettings.comment"> | ||||||
|           <td>{{ i18n "comment" }}</td> |           <td>{{ i18n "comment" }}</td> | ||||||
|           <td> |           <td> | ||||||
|  |  | ||||||
|  | @ -967,6 +967,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr | ||||||
| 				// Add user in onlineUsers array on traffic
 | 				// Add user in onlineUsers array on traffic
 | ||||||
| 				if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 { | 				if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 { | ||||||
| 					onlineClients = append(onlineClients, traffics[traffic_index].Email) | 					onlineClients = append(onlineClients, traffics[traffic_index].Email) | ||||||
|  | 					dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli() | ||||||
| 				} | 				} | ||||||
| 				break | 				break | ||||||
| 			} | 			} | ||||||
|  | @ -2187,6 +2188,20 @@ func (s *InboundService) GetOnlineClients() []string { | ||||||
| 	return p.GetOnlineClients() | 	return p.GetOnlineClients() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) { | ||||||
|  | 	db := database.GetDB() | ||||||
|  | 	var rows []xray.ClientTraffic | ||||||
|  | 	err := db.Model(&xray.ClientTraffic{}).Select("email, last_online").Find(&rows).Error | ||||||
|  | 	if err != nil && err != gorm.ErrRecordNotFound { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	result := make(map[string]int64, len(rows)) | ||||||
|  | 	for _, r := range rows { | ||||||
|  | 		result[r.Email] = r.LastOnline | ||||||
|  | 	} | ||||||
|  | 	return result, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) { | func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) { | ||||||
| 	db := database.GetDB() | 	db := database.GetDB() | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "فشل" | "fail" = "فشل" | ||||||
| "comment" = "تعليق" | "comment" = "تعليق" | ||||||
| "success" = "تم بنجاح" | "success" = "تم بنجاح" | ||||||
|  | "lastOnline" = "آخر متصل" | ||||||
| "getVersion" = "جيب النسخة" | "getVersion" = "جيب النسخة" | ||||||
| "install" = "تثبيت" | "install" = "تثبيت" | ||||||
| "clients" = "عملاء" | "clients" = "عملاء" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Failed" | "fail" = "Failed" | ||||||
| "comment" = "Comment" | "comment" = "Comment" | ||||||
| "success" = "Successfully" | "success" = "Successfully" | ||||||
|  | "lastOnline" = "Last Online" | ||||||
| "getVersion" = "Get Version" | "getVersion" = "Get Version" | ||||||
| "install" = "Install" | "install" = "Install" | ||||||
| "clients" = "Clients" | "clients" = "Clients" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Falló" | "fail" = "Falló" | ||||||
| "comment" = "Comentario" | "comment" = "Comentario" | ||||||
| "success" = "Éxito" | "success" = "Éxito" | ||||||
|  | "lastOnline" = "Última conexión" | ||||||
| "getVersion" = "Obtener versión" | "getVersion" = "Obtener versión" | ||||||
| "install" = "Instalar" | "install" = "Instalar" | ||||||
| "clients" = "Clientes" | "clients" = "Clientes" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "ناموفق" | "fail" = "ناموفق" | ||||||
| "comment" = "توضیحات" | "comment" = "توضیحات" | ||||||
| "success" = "موفق" | "success" = "موفق" | ||||||
|  | "lastOnline" = "آخرین فعالیت" | ||||||
| "getVersion" = "دریافت نسخه" | "getVersion" = "دریافت نسخه" | ||||||
| "install" = "نصب" | "install" = "نصب" | ||||||
| "clients" = "کاربران" | "clients" = "کاربران" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Gagal" | "fail" = "Gagal" | ||||||
| "comment" = "Komentar" | "comment" = "Komentar" | ||||||
| "success" = "Berhasil" | "success" = "Berhasil" | ||||||
|  | "lastOnline" = "Terakhir online" | ||||||
| "getVersion" = "Dapatkan Versi" | "getVersion" = "Dapatkan Versi" | ||||||
| "install" = "Instal" | "install" = "Instal" | ||||||
| "clients" = "Klien" | "clients" = "Klien" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "失敗" | "fail" = "失敗" | ||||||
| "comment" = "コメント" | "comment" = "コメント" | ||||||
| "success" = "成功" | "success" = "成功" | ||||||
|  | "lastOnline" = "最終オンライン" | ||||||
| "getVersion" = "バージョン取得" | "getVersion" = "バージョン取得" | ||||||
| "install" = "インストール" | "install" = "インストール" | ||||||
| "clients" = "クライアント" | "clients" = "クライアント" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Falhou" | "fail" = "Falhou" | ||||||
| "comment" = "Comentário" | "comment" = "Comentário" | ||||||
| "success" = "Com Sucesso" | "success" = "Com Sucesso" | ||||||
|  | "lastOnline" = "Última vez online" | ||||||
| "getVersion" = "Obter Versão" | "getVersion" = "Obter Versão" | ||||||
| "install" = "Instalar" | "install" = "Instalar" | ||||||
| "clients" = "Clientes" | "clients" = "Clientes" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Ошибка" | "fail" = "Ошибка" | ||||||
| "comment" = "Комментарий" | "comment" = "Комментарий" | ||||||
| "success" = "Успешно" | "success" = "Успешно" | ||||||
|  | "lastOnline" = "Был(а) в сети" | ||||||
| "getVersion" = "Узнать версию" | "getVersion" = "Узнать версию" | ||||||
| "install" = "Установка" | "install" = "Установка" | ||||||
| "clients" = "Клиенты" | "clients" = "Клиенты" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Başarısız" | "fail" = "Başarısız" | ||||||
| "comment" = "Yorum" | "comment" = "Yorum" | ||||||
| "success" = "Başarılı" | "success" = "Başarılı" | ||||||
|  | "lastOnline" = "Son çevrimiçi" | ||||||
| "getVersion" = "Sürümü Al" | "getVersion" = "Sürümü Al" | ||||||
| "install" = "Yükle" | "install" = "Yükle" | ||||||
| "clients" = "Müşteriler" | "clients" = "Müşteriler" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Помилка" | "fail" = "Помилка" | ||||||
| "comment" = "Коментар" | "comment" = "Коментар" | ||||||
| "success" = "Успішно" | "success" = "Успішно" | ||||||
|  | "lastOnline" = "Був(ла) онлайн" | ||||||
| "getVersion" = "Отримати версію" | "getVersion" = "Отримати версію" | ||||||
| "install" = "Встановити" | "install" = "Встановити" | ||||||
| "clients" = "Клієнти" | "clients" = "Клієнти" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "Thất bại" | "fail" = "Thất bại" | ||||||
| "comment" = "Bình luận" | "comment" = "Bình luận" | ||||||
| "success" = "Thành công" | "success" = "Thành công" | ||||||
|  | "lastOnline" = "Lần online gần nhất" | ||||||
| "getVersion" = "Lấy phiên bản" | "getVersion" = "Lấy phiên bản" | ||||||
| "install" = "Cài đặt" | "install" = "Cài đặt" | ||||||
| "clients" = "Các khách hàng" | "clients" = "Các khách hàng" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "失败" | "fail" = "失败" | ||||||
| "comment" = "评论" | "comment" = "评论" | ||||||
| "success" = "成功" | "success" = "成功" | ||||||
|  | "lastOnline" = "上次在线" | ||||||
| "getVersion" = "获取版本" | "getVersion" = "获取版本" | ||||||
| "install" = "安装" | "install" = "安装" | ||||||
| "clients" = "客户端" | "clients" = "客户端" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ | ||||||
| "fail" = "失敗" | "fail" = "失敗" | ||||||
| "comment" = "評論" | "comment" = "評論" | ||||||
| "success" = "成功" | "success" = "成功" | ||||||
|  | "lastOnline" = "上次上線" | ||||||
| "getVersion" = "獲取版本" | "getVersion" = "獲取版本" | ||||||
| "install" = "安裝" | "install" = "安裝" | ||||||
| "clients" = "客戶端" | "clients" = "客戶端" | ||||||
|  |  | ||||||
|  | @ -11,4 +11,5 @@ type ClientTraffic struct { | ||||||
| 	ExpiryTime int64  `json:"expiryTime" form:"expiryTime"` | 	ExpiryTime int64  `json:"expiryTime" form:"expiryTime"` | ||||||
| 	Total      int64  `json:"total" form:"total"` | 	Total      int64  `json:"total" form:"total"` | ||||||
| 	Reset      int    `json:"reset" form:"reset" gorm:"default:0"` | 	Reset      int    `json:"reset" form:"reset" gorm:"default:0"` | ||||||
|  | 	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0"` | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue
	
	 Ali Golzar
						Ali Golzar