Browse Source

feat: add "Last Online" column to client list and modal (Closes #3402) (#3405)

* 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
Ali Golzar 1 week ago
parent
commit
4a0914cb1e

+ 1 - 1
web/assets/js/util/date-util.js

@@ -134,7 +134,7 @@ class DateUtil {
     }
 
     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() {

+ 1 - 0
web/controller/api.go

@@ -47,6 +47,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
 		{"POST", "/resetAllClientTraffics/:id", a.inboundController.resetAllClientTraffics},
 		{"POST", "/delDepletedClients/:id", a.inboundController.delDepletedClients},
 		{"POST", "/onlines", a.inboundController.onlines},
+		{"POST", "/lastOnline", a.inboundController.lastOnline},
 		{"POST", "/updateClientTraffic/:email", a.inboundController.updateClientTraffic},
 	}
 

+ 5 - 0
web/controller/inbound.go

@@ -340,6 +340,11 @@ func (a *InboundController) onlines(c *gin.Context) {
 	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) {
 	email := c.Param("email")
 

+ 11 - 6
web/html/component/aClientTable.html

@@ -33,12 +33,17 @@
   <a-switch v-model="client.enable" @change="switchEnableClient(record.id,client)"></a-switch>
 </template>
 <template slot="online" slot-scope="text, client, index">
-  <template v-if="client.enable && isClientOnline(client.email)">
-    <a-tag color="green">{{ i18n "online" }}</a-tag>
-  </template>
-  <template v-else>
-    <a-tag>{{ i18n "offline" }}</a-tag>
-  </template>
+  <a-popover :overlay-class-name="themeSwitcher.currentTheme">
+    <template slot="content" >
+      {{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]]
+    </template>
+    <template v-if="client.enable && isClientOnline(client.email)">
+      <a-tag color="green">{{ i18n "online" }}</a-tag>
+    </template>
+    <template v-else>
+      <a-tag>{{ i18n "offline" }}</a-tag>
+    </template>
+  </a-popover>
 </template>
 <template slot="client" slot-scope="text, client">
   <a-space direction="horizontal" :size="2">

+ 18 - 0
web/html/inbounds.html

@@ -807,6 +807,7 @@
             defaultKey: '',
             clientCount: [],
             onlineClients: [],
+            lastOnlineMap: {},
             isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
             refreshing: false,
             refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
@@ -835,6 +836,7 @@
                     return;
                 }
 
+                await this.getLastOnlineMap();
                 await this.getOnlineUsers();
                 
                 this.setInbounds(msg.obj);
@@ -849,6 +851,11 @@
                 }
                 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() {
                 const msg = await HttpUtil.post('/panel/setting/defaultSettings');
                 if (!msg.success) {
@@ -1493,6 +1500,17 @@
             isClientOnline(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) {
                 return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
             },

+ 6 - 0
web/html/modals/inbound_info_modal.html

@@ -217,6 +217,12 @@
             </template>
           </td>
         </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">
           <td>{{ i18n "comment" }}</td>
           <td>

+ 15 - 0
web/service/inbound.go

@@ -967,6 +967,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 				// Add user in onlineUsers array on traffic
 				if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 {
 					onlineClients = append(onlineClients, traffics[traffic_index].Email)
+					dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli()
 				}
 				break
 			}
@@ -2187,6 +2188,20 @@ func (s *InboundService) GetOnlineClients() []string {
 	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) {
 	db := database.GetDB()
 

+ 1 - 0
web/translation/translate.ar_EG.toml

@@ -50,6 +50,7 @@
 "fail" = "فشل"
 "comment" = "تعليق"
 "success" = "تم بنجاح"
+"lastOnline" = "آخر متصل"
 "getVersion" = "جيب النسخة"
 "install" = "تثبيت"
 "clients" = "عملاء"

+ 1 - 0
web/translation/translate.en_US.toml

@@ -50,6 +50,7 @@
 "fail" = "Failed"
 "comment" = "Comment"
 "success" = "Successfully"
+"lastOnline" = "Last Online"
 "getVersion" = "Get Version"
 "install" = "Install"
 "clients" = "Clients"

+ 1 - 0
web/translation/translate.es_ES.toml

@@ -50,6 +50,7 @@
 "fail" = "Falló"
 "comment" = "Comentario"
 "success" = "Éxito"
+"lastOnline" = "Última conexión"
 "getVersion" = "Obtener versión"
 "install" = "Instalar"
 "clients" = "Clientes"

+ 1 - 0
web/translation/translate.fa_IR.toml

@@ -50,6 +50,7 @@
 "fail" = "ناموفق"
 "comment" = "توضیحات"
 "success" = "موفق"
+"lastOnline" = "آخرین فعالیت"
 "getVersion" = "دریافت نسخه"
 "install" = "نصب"
 "clients" = "کاربران"

+ 1 - 0
web/translation/translate.id_ID.toml

@@ -50,6 +50,7 @@
 "fail" = "Gagal"
 "comment" = "Komentar"
 "success" = "Berhasil"
+"lastOnline" = "Terakhir online"
 "getVersion" = "Dapatkan Versi"
 "install" = "Instal"
 "clients" = "Klien"

+ 1 - 0
web/translation/translate.ja_JP.toml

@@ -50,6 +50,7 @@
 "fail" = "失敗"
 "comment" = "コメント"
 "success" = "成功"
+"lastOnline" = "最終オンライン"
 "getVersion" = "バージョン取得"
 "install" = "インストール"
 "clients" = "クライアント"

+ 1 - 0
web/translation/translate.pt_BR.toml

@@ -50,6 +50,7 @@
 "fail" = "Falhou"
 "comment" = "Comentário"
 "success" = "Com Sucesso"
+"lastOnline" = "Última vez online"
 "getVersion" = "Obter Versão"
 "install" = "Instalar"
 "clients" = "Clientes"

+ 1 - 0
web/translation/translate.ru_RU.toml

@@ -50,6 +50,7 @@
 "fail" = "Ошибка"
 "comment" = "Комментарий"
 "success" = "Успешно"
+"lastOnline" = "Был(а) в сети"
 "getVersion" = "Узнать версию"
 "install" = "Установка"
 "clients" = "Клиенты"

+ 1 - 0
web/translation/translate.tr_TR.toml

@@ -50,6 +50,7 @@
 "fail" = "Başarısız"
 "comment" = "Yorum"
 "success" = "Başarılı"
+"lastOnline" = "Son çevrimiçi"
 "getVersion" = "Sürümü Al"
 "install" = "Yükle"
 "clients" = "Müşteriler"

+ 1 - 0
web/translation/translate.uk_UA.toml

@@ -50,6 +50,7 @@
 "fail" = "Помилка"
 "comment" = "Коментар"
 "success" = "Успішно"
+"lastOnline" = "Був(ла) онлайн"
 "getVersion" = "Отримати версію"
 "install" = "Встановити"
 "clients" = "Клієнти"

+ 1 - 0
web/translation/translate.vi_VN.toml

@@ -50,6 +50,7 @@
 "fail" = "Thất bại"
 "comment" = "Bình luận"
 "success" = "Thành công"
+"lastOnline" = "Lần online gần nhất"
 "getVersion" = "Lấy phiên bản"
 "install" = "Cài đặt"
 "clients" = "Các khách hàng"

+ 1 - 0
web/translation/translate.zh_CN.toml

@@ -50,6 +50,7 @@
 "fail" = "失败"
 "comment" = "评论"
 "success" = "成功"
+"lastOnline" = "上次在线"
 "getVersion" = "获取版本"
 "install" = "安装"
 "clients" = "客户端"

+ 1 - 0
web/translation/translate.zh_TW.toml

@@ -50,6 +50,7 @@
 "fail" = "失敗"
 "comment" = "評論"
 "success" = "成功"
+"lastOnline" = "上次上線"
 "getVersion" = "獲取版本"
 "install" = "安裝"
 "clients" = "客戶端"

+ 1 - 0
xray/client_traffic.go

@@ -11,4 +11,5 @@ type ClientTraffic struct {
 	ExpiryTime int64  `json:"expiryTime" form:"expiryTime"`
 	Total      int64  `json:"total" form:"total"`
 	Reset      int    `json:"reset" form:"reset" gorm:"default:0"`
+	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0"`
 }