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

* 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:
Ali Golzar 2025-08-31 20:03:50 +03:30 committed by GitHub
parent 664269d513
commit 4a0914cb1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 71 additions and 7 deletions

View file

@ -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() {

View file

@ -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},
}

View file

@ -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")

View file

@ -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">
<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">

View file

@ -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;
},

View file

@ -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>

View file

@ -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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

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

View file

@ -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"

View file

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

View file

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

View file

@ -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"`
}