fix(expiry): show delayed-start countdown in subscribe and client info (#4535)

A client with "start after first use" expiry stores the duration as a
negative number of milliseconds (e.g. -86400000 = 1 day after first
connect). The clients page row already renders this correctly as
"Delayed start: 1d", but two other surfaces treated negative values as
zero and rendered them as unlimited:

- Subscription header: the index==0 / index>0 branches in subService,
  subClashService and subJsonService only carried ExpiryTime forward
  when > 0, so traffic.ExpiryTime stayed at zero and the header sent
  expire=0. Every imported client appeared to have no expiry, and the
  built-in subscribe page rendered the "unlimited" tag.

- ClientInfoModal: both the expiryLabel helper and the rendering check
  treated <= 0 as the "no expiry" branch, so the modal showed an
  infinity tag instead of "Delayed start: Nd".

Add subscriptionExpiryFromClient to map negative durations onto a
"now + |value|" timestamp so subscription clients see an actual expiry
they can count down from. Update ClientInfoModal's helper and render
to match the clients-page convention.

Regression test in subService_test.go covers the helper.

Refs #4535
This commit is contained in:
MHSanaei 2026-05-24 23:15:44 +02:00
parent 97967535b6
commit 64122ad80f
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 47 additions and 16 deletions

View file

@ -40,9 +40,16 @@ export default function ClientInfoModal({
onOpenChange, onOpenChange,
}: ClientInfoModalProps) { }: ClientInfoModalProps) {
const { datepicker } = useDatepicker(); const { datepicker } = useDatepicker();
const expiryLabel = (ts?: number) => (!ts || ts <= 0 ? '∞' : IntlUtil.formatDate(ts, datepicker));
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
const { t } = useTranslation(); const { t } = useTranslation();
const expiryLabel = (ts?: number) => {
if (!ts) return '∞';
if (ts < 0) {
const days = Math.round(ts / -86400000);
return `${t('pages.clients.delayedStart')}: ${days}d`;
}
return IntlUtil.formatDate(ts, datepicker);
};
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [links, setLinks] = useState<string[]>([]); const [links, setLinks] = useState<string[]>([]);
@ -195,9 +202,9 @@ export default function ClientInfoModal({
<tr> <tr>
<td>{t('pages.inbounds.expireDate')}</td> <td>{t('pages.inbounds.expireDate')}</td>
<td> <td>
{!client.expiryTime || client.expiryTime <= 0 {!client.expiryTime
? <Tag color="purple"></Tag> ? <Tag color="purple"></Tag>
: <Tag>{expiryLabel(client.expiryTime)}</Tag>} : <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
{(client.expiryTime ?? 0) > 0 && ( {(client.expiryTime ?? 0) > 0 && (
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span> <span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
)} )}

View file

@ -68,9 +68,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
traffic.Up = clientTraffic.Up traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total traffic.Total = clientTraffic.Total
if clientTraffic.ExpiryTime > 0 { traffic.ExpiryTime = subscriptionExpiryFromClient(clientTraffic.ExpiryTime)
traffic.ExpiryTime = clientTraffic.ExpiryTime
}
} else { } else {
traffic.Up += clientTraffic.Up traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down traffic.Down += clientTraffic.Down
@ -79,7 +77,8 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
} else { } else {
traffic.Total += clientTraffic.Total traffic.Total += clientTraffic.Total
} }
if clientTraffic.ExpiryTime != traffic.ExpiryTime { normalized := subscriptionExpiryFromClient(clientTraffic.ExpiryTime)
if normalized != traffic.ExpiryTime {
traffic.ExpiryTime = 0 traffic.ExpiryTime = 0
} }
} }

View file

@ -130,9 +130,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
traffic.Up = clientTraffic.Up traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total traffic.Total = clientTraffic.Total
if clientTraffic.ExpiryTime > 0 { traffic.ExpiryTime = subscriptionExpiryFromClient(clientTraffic.ExpiryTime)
traffic.ExpiryTime = clientTraffic.ExpiryTime
}
} else { } else {
traffic.Up += clientTraffic.Up traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down traffic.Down += clientTraffic.Down
@ -141,7 +139,8 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
} else { } else {
traffic.Total += clientTraffic.Total traffic.Total += clientTraffic.Total
} }
if clientTraffic.ExpiryTime != traffic.ExpiryTime { normalized := subscriptionExpiryFromClient(clientTraffic.ExpiryTime)
if normalized != traffic.ExpiryTime {
traffic.ExpiryTime = 0 traffic.ExpiryTime = 0
} }
} }

View file

@ -114,9 +114,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
traffic.Up = clientTraffic.Up traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total traffic.Total = clientTraffic.Total
if clientTraffic.ExpiryTime > 0 { traffic.ExpiryTime = subscriptionExpiryFromClient(clientTraffic.ExpiryTime)
traffic.ExpiryTime = clientTraffic.ExpiryTime
}
} else { } else {
traffic.Up += clientTraffic.Up traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down traffic.Down += clientTraffic.Down
@ -125,7 +123,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
} else { } else {
traffic.Total += clientTraffic.Total traffic.Total += clientTraffic.Total
} }
if clientTraffic.ExpiryTime != traffic.ExpiryTime { normalized := subscriptionExpiryFromClient(clientTraffic.ExpiryTime)
if normalized != traffic.ExpiryTime {
traffic.ExpiryTime = 0 traffic.ExpiryTime = 0
} }
} }
@ -134,6 +133,16 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
return result, lastOnline, traffic, nil return result, lastOnline, traffic, nil
} }
func subscriptionExpiryFromClient(expiryTime int64) int64 {
if expiryTime > 0 {
return expiryTime
}
if expiryTime < 0 {
return time.Now().UnixMilli() + (-expiryTime)
}
return 0
}
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
db := database.GetDB() db := database.GetDB()
var inbounds []*model.Inbound var inbounds []*model.Inbound

View file

@ -5,10 +5,27 @@ import (
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
) )
func TestSubscriptionExpiryFromClient(t *testing.T) {
if got := subscriptionExpiryFromClient(0); got != 0 {
t.Fatalf("zero expiry should stay zero, got %d", got)
}
if got := subscriptionExpiryFromClient(1_700_000_000_000); got != 1_700_000_000_000 {
t.Fatalf("positive expiry should pass through, got %d", got)
}
const oneDayMs = int64(86_400_000)
before := time.Now().UnixMilli()
got := subscriptionExpiryFromClient(-oneDayMs)
after := time.Now().UnixMilli()
if got < before+oneDayMs || got > after+oneDayMs {
t.Fatalf("delayed-start expiry should land ~1 day from now, got %d (window %d..%d)", got, before+oneDayMs, after+oneDayMs)
}
}
func TestFindClientIndex(t *testing.T) { func TestFindClientIndex(t *testing.T) {
clients := []model.Client{ clients := []model.Client{
{Email: "a@example.com"}, {Email: "a@example.com"},