diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index f90253e25..d15f3a311 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -6540,7 +6540,7 @@ "tags": [ "Clients" ], - "summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.", + "summary": "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.", "operationId": "post_panel_api_clients_email_externalLinks", "parameters": [ { @@ -6558,19 +6558,36 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "externalLinks": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET." + } + }, + "required": [ + "externalLinks" + ] }, "example": { "externalLinks": [ { "kind": "link", "value": "vless://uuid@host:443?...#srv", - "remark": "DE" + "remark": "DE", + "enable": true, + "expiryTime": 0 }, { "kind": "subscription", "value": "https://provider.example/sub/abc", - "remark": "Provider" + "remark": "Provider", + "enable": false, + "expiryTime": 1767225600000, + "namePrefix": "[zjh] " } ] } diff --git a/frontend/scripts/build-openapi.mjs b/frontend/scripts/build-openapi.mjs index eb2401bfd..91003b1a5 100644 --- a/frontend/scripts/build-openapi.mjs +++ b/frontend/scripts/build-openapi.mjs @@ -40,6 +40,7 @@ function extractPathParams(openApiPath) { function mapType(t) { const v = String(t || '').toLowerCase(); + if (v.endsWith('[]')) return 'array'; if (v === 'number' || v === 'integer' || v === 'int') return 'integer'; if (v === 'float' || v === 'double') return 'number'; if (v === 'boolean' || v === 'bool') return 'boolean'; @@ -48,6 +49,15 @@ function mapType(t) { return 'string'; } +function schemaFromType(t) { + const v = String(t || '').toLowerCase(); + if (v.endsWith('[]')) { + const itemType = v.slice(0, -2); + return { type: 'array', items: { type: mapType(itemType) } }; + } + return { type: mapType(v) }; +} + function tryParseJson(raw) { if (typeof raw !== 'string') return undefined; try { @@ -63,7 +73,7 @@ function paramToOpenApi(p) { in: p.in, required: p.in === 'path' ? true : !p.optional, description: p.desc || '', - schema: { type: mapType(p.type) }, + schema: schemaFromType(p.type), }; if (p.defaultValue !== undefined) out.schema.default = p.defaultValue; return out; @@ -109,7 +119,7 @@ function buildOperation(ep, tag) { const required = []; for (const bp of bodyParams) { properties[bp.name] = { - type: mapType(bp.type), + ...schemaFromType(bp.type), description: bp.desc || '', }; if (!bp.optional) required.push(bp.name); diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 7ac59bbeb..15403ae62 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -35,7 +35,14 @@ import { DefaultsPayloadSchema } from '@/schemas/defaults'; import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval'; // One row sent to POST /clients/:email/externalLinks. -export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string }; +export type ExternalLinkInput = { + kind: 'link' | 'subscription'; + value: string; + remark: string; + enable: boolean; + expiryTime: number; + namePrefix: string; +}; export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink }; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index ef421d9dd..06ad06b0d 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -597,7 +597,7 @@ export const sections: readonly Section[] = [ { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, ], response: - '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}', + '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}', }, { method: 'GET', @@ -665,12 +665,12 @@ export const sections: readonly Section[] = [ { method: 'POST', path: '/panel/api/clients/:email/externalLinks', - summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.', + summary: 'Replace a client\'s external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.', params: [ { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, - { name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' }, + { name: 'externalLinks', in: 'body', type: 'object[]', desc: 'Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.' }, ], - body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n ]\n}', + body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\n ]\n}', response: '{\n "success": true\n}', }, { diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 3dd4e34dc..ee33f3e07 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -22,7 +22,7 @@ import { import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; -import { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form'; +import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form'; import { HttpUtil, RandomUtil, Wireguard } from '@/utils'; import { formatInboundLabel } from '@/lib/inbounds/label'; @@ -50,6 +50,11 @@ interface ExternalLinkRow { kind: 'link' | 'subscription'; value: string; remark: string; + enable: boolean; + expiryTime: number; + namePrefix: string; + lastFetchAt: number; + lastFetchError: string; } interface ApiMsg { @@ -157,6 +162,11 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[ kind: l.kind === 'subscription' ? 'subscription' : 'link', value: l.value || '', remark: l.remark || '', + enable: l.enable !== false, + expiryTime: Number(l.expiryTime) || 0, + namePrefix: l.namePrefix || '', + lastFetchAt: Number(l.lastFetchAt) || 0, + lastFetchError: l.lastFetchError || '', })); } @@ -232,7 +242,16 @@ export default function ClientFormModal({ const limitIpNotice = getLimitIpNotice(fail2ban, t); function addExternalLinkRow(kind: 'link' | 'subscription') { - appendExternalLink({ kind, value: '', remark: '' }); + appendExternalLink({ + kind, + value: '', + remark: '', + enable: true, + expiryTime: 0, + namePrefix: '', + lastFetchAt: 0, + lastFetchError: '', + }); } useEffect(() => { @@ -622,7 +641,14 @@ reset: Number(values.reset) || 0, } const externalLinks: ExternalLinkInput[] = values.externalLinks - .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() })) + .map((r) => ({ + kind: r.kind, + value: r.value.trim(), + remark: (r.remark || '').trim(), + enable: r.enable !== false, + expiryTime: Number(r.expiryTime) || 0, + namePrefix: (r.namePrefix || '').trim(), + })) .filter((r) => r.value !== ''); setSubmitting(true); @@ -1043,24 +1069,40 @@ reset: Number(values.reset) || 0, {linkRows.length === 0 ? ( {t('pages.clients.noExternalLinks')} ) : linkRows.map(({ field, index }) => ( -
- - +
+
+ + + + {t('enable')} +
+ + + + +
+
+ + + + ( + 0 ? dayjs(Number(expiryField.value)) : null} + onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} + placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + /> + )} /> - - - - - -
))} @@ -1072,17 +1114,50 @@ reset: Number(values.reset) || 0, {subscriptionRows.length === 0 ? ( {t('pages.clients.noExternalSubscriptions')} ) : subscriptionRows.map(({ field, index }) => ( -
- - +
+
+ + + + {t('enable')} +
+ + + + +
+
+ + + + + + + ( + 0 ? dayjs(Number(expiryField.value)) : null} + onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} + placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + /> + )} /> - - -
+ + {field.lastFetchError + ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}` + : field.lastFetchAt > 0 + ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}` + : t('pages.clients.neverFetched')} +
))} diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index 93d63c783..baaa88629 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/frontend/src/pages/clients/ClientsPage.css @@ -83,6 +83,76 @@ line-height: 18px; } +.external-link-card { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; + padding: 10px; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 6px; + background: var(--ant-color-fill-quaternary); +} + +.external-link-row { + display: flex; + align-items: center; + gap: 10px; +} + +.external-link-row .ant-input { + flex: 1; + min-width: 0; +} + +.external-link-enable { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 78px; + color: var(--ant-color-text-secondary); + white-space: nowrap; +} + +.external-link-details { + display: grid; + gap: 10px; +} + +.external-link-details.two-cols { + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr); +} + +.external-link-details.three-cols { + grid-template-columns: minmax(0, 1fr) minmax(160px, 0.8fr) minmax(220px, 0.8fr); +} + +.external-link-fetch-status { + font-size: 12px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .external-link-row { + align-items: stretch; + flex-wrap: wrap; + } + + .external-link-enable { + width: 100%; + } + + .external-link-row .ant-input { + flex-basis: calc(100% - 44px); + } + + .external-link-details.two-cols, + .external-link-details.three-cols { + grid-template-columns: 1fr; + } +} + .card-toolbar { display: flex; align-items: center; diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 3dc4d5dc4..554aac0eb 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -106,9 +106,15 @@ export const ClientPageResponseSchema = z.object({ // A per-client external link surfaced in the client's subscription: // kind=link is a single share link, kind=subscription is a remote sub URL. export const ExternalLinkSchema = z.object({ + id: z.number().int().optional().default(0), kind: z.enum(['link', 'subscription']).default('link'), value: z.string(), remark: z.string().optional().default(''), + enable: z.preprocess((v) => (v == null ? true : v), z.boolean()).default(true), + expiryTime: z.number().int().optional().default(0), + namePrefix: z.string().optional().default(''), + lastFetchAt: z.number().int().optional().default(0), + lastFetchError: z.string().optional().default(''), }).loose(); export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []); diff --git a/internal/database/db.go b/internal/database/db.go index aee7f08ce..33b7f8c2e 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -137,6 +137,12 @@ func initModels() error { if err := normalizeInboundSubSortIndex(); err != nil { return err } + if err := normalizeClientExternalLinkEnable(); err != nil { + return err + } + if err := normalizeClientExternalLinkTimestamps(); err != nil { + return err + } if err := repairOverflowedTrafficCounters(); err != nil { return err } @@ -965,6 +971,40 @@ func normalizeInboundSubSortIndex() error { return nil } +// normalizeClientExternalLinkEnable keeps external-link rows written before the +// enable column existed enabled; disabled rows from newer builds stay false. +func normalizeClientExternalLinkEnable() error { + res := db.Exec("UPDATE client_external_links SET enable = ? WHERE enable IS NULL", true) + if res.Error != nil { + log.Printf("Error normalizing client external link enable: %v", res.Error) + return res.Error + } + if res.RowsAffected > 0 { + log.Printf("Normalized enable on %d client external link(s)", res.RowsAffected) + } + return nil +} + +// normalizeClientExternalLinkTimestamps zeroes the NULLs an older build could +// leave behind, so the sub-side expiry predicate never drops a legacy row. +func normalizeClientExternalLinkTimestamps() error { + res := db.Exec("UPDATE client_external_links SET expiry_time = 0 WHERE expiry_time IS NULL") + if res.Error != nil { + log.Printf("Error normalizing client external link expiry_time: %v", res.Error) + return res.Error + } + expiryRows := res.RowsAffected + res = db.Exec("UPDATE client_external_links SET last_fetch_at = 0 WHERE last_fetch_at IS NULL") + if res.Error != nil { + log.Printf("Error normalizing client external link last_fetch_at: %v", res.Error) + return res.Error + } + if expiryRows+res.RowsAffected > 0 { + log.Printf("Normalized timestamps on %d client external link(s)", expiryRows+res.RowsAffected) + } + return nil +} + // repairOverflowedTrafficCounters heals traffic counters that historic // compounding bugs pushed past int64: on SQLite an overflowing INTEGER is // silently promoted to REAL, after which the column no longer scans into the diff --git a/internal/database/model/model.go b/internal/database/model/model.go index c79e1a98f..b99912da4 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -1015,13 +1015,18 @@ func (ClientHwid) TableName() string { return "client_hwids" } // - "subscription": a remote subscription URL. The panel fetches it (cached), // decodes its links, and merges them into the client's subscription. type ClientExternalLink struct { - Id int `json:"id" gorm:"primaryKey;autoIncrement"` - ClientId int `json:"clientId" gorm:"index;column:client_id"` - Kind string `json:"kind" gorm:"column:kind"` - Value string `json:"value" gorm:"column:value"` - Remark string `json:"remark" gorm:"column:remark"` - SortIndex int `json:"sortIndex" gorm:"column:sort_index"` - CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + ClientId int `json:"clientId" gorm:"index;column:client_id"` + Kind string `json:"kind" gorm:"column:kind"` + Value string `json:"value" gorm:"column:value"` + Remark string `json:"remark" gorm:"column:remark"` + Enable *bool `json:"enable" gorm:"column:enable;default:true"` + ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time;default:0"` + NamePrefix string `json:"namePrefix" gorm:"column:name_prefix"` + LastFetchAt int64 `json:"lastFetchAt" gorm:"column:last_fetch_at;default:0"` + LastFetchError string `json:"lastFetchError" gorm:"column:last_fetch_error"` + SortIndex int `json:"sortIndex" gorm:"column:sort_index"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` } func (ClientExternalLink) TableName() string { return "client_external_links" } diff --git a/internal/sub/external_config.go b/internal/sub/external_config.go index cd63c26a0..9505a1abc 100644 --- a/internal/sub/external_config.go +++ b/internal/sub/external_config.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "net/url" "strings" + "time" "github.com/goccy/go-json" @@ -16,11 +17,12 @@ import ( // externalLinkEntry is one client × external-link row, resolved for a // subscription request. Email/Enable come from the owning client. type externalLinkEntry struct { - Kind string - Value string - Remark string - Email string - Enable bool + Kind string + Value string + Remark string + NamePrefix string + Email string + Enable bool } // expandedLink is a single share link contributed by an entry, with the display @@ -50,7 +52,10 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink } var rows []model.ClientExternalLink + now := time.Now().UnixMilli() if err := db.Where("client_id IN ?", clientIds). + Where("(enable IS NULL OR enable = ?)", true). + Where("(expiry_time IS NULL OR expiry_time <= 0 OR expiry_time > ?)", now). Order("client_id ASC, sort_index ASC, id ASC"). Find(&rows).Error; err != nil { return nil, err @@ -63,27 +68,28 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink for _, r := range rows { rec := byId[r.ClientId] out = append(out, externalLinkEntry{ - Kind: r.Kind, - Value: r.Value, - Remark: r.Remark, - Email: rec.Email, - Enable: rec.Enable, + Kind: r.Kind, + Value: r.Value, + Remark: r.Remark, + NamePrefix: r.NamePrefix, + Email: rec.Email, + Enable: rec.Enable, }) } return out, nil } -// expandEntry turns one entry into the concrete share links it contributes. A -// "subscription" entry is fetched (cached) and its links keep their own names -// (URL #fragment / vmess ps). A "link" entry uses the row remark when set, -// otherwise the link's original name — never blank, so Clash/JSON do not fall -// back to the client email. +// expandEntry turns one entry into the concrete share links it contributes. +// Names are never blank, so Clash/JSON do not fall back to the client email. func expandEntry(e externalLinkEntry) []expandedLink { if e.Kind == model.ExternalLinkKindSubscription { - links := fetchSubscriptionLinks(e.Value) - out := make([]expandedLink, 0, len(links)) - for _, l := range links { - out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)}) + res := fetchSubscriptionLinks(e.Value) + if res.fetched { + recordExternalSubscriptionFetch(e.Value, res.err) + } + out := make([]expandedLink, 0, len(res.links)) + for _, l := range res.links { + out = append(out, expandedLink{Link: l, Name: prefixedLinkName(linkDisplayName(l), e.NamePrefix, e.Email)}) } return out } @@ -129,6 +135,19 @@ func linkDisplayName(rawLink string) string { return "" } +// prefixedLinkName falls back to the client email so a prefixed row never +// renders as the bare prefix when the link carries no name of its own. +func prefixedLinkName(displayName, prefix, fallback string) string { + if strings.TrimSpace(prefix) == "" { + return displayName + } + name := displayName + if name == "" { + name = strings.TrimSpace(fallback) + } + return prefix + name +} + // applyRemarkToLink rewrites a share link's display name to remark (when set), // leaving everything else byte-for-byte. vmess carries its remark in the base64 // JSON `ps`; every other scheme carries it in the URL #fragment. diff --git a/internal/sub/external_config_test.go b/internal/sub/external_config_test.go index 352f7851f..c42be14b6 100644 --- a/internal/sub/external_config_test.go +++ b/internal/sub/external_config_test.go @@ -5,6 +5,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/goccy/go-json" @@ -98,6 +99,31 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) { } } +func TestExpandEntrySubscriptionAppliesNamePrefix(t *testing.T) { + const subURL = "https://provider.example/sub-prefix" + subscriptionCache.Lock() + subscriptionCache.m[subURL] = subscriptionCacheEntry{ + links: []string{"trojan://pw@b.com:8443#HK-01"}, + fetchedAt: time.Now(), + } + subscriptionCache.Unlock() + t.Cleanup(func() { + subscriptionCache.Lock() + delete(subscriptionCache.m, subURL) + subscriptionCache.Unlock() + }) + + got := expandEntry(externalLinkEntry{ + Kind: model.ExternalLinkKindSubscription, + Value: subURL, + NamePrefix: "[zjh] ", + Email: "zjh", + }) + if len(got) != 1 || got[0].Name != "[zjh] HK-01" { + t.Fatalf("expandEntry = %#v", got) + } +} + func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) { got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@b.com:8443#orig", Remark: ""}) if len(got) != 1 || got[0].Name != "orig" { diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index 7098e8db6..862cbb496 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -8,6 +8,10 @@ import ( "strings" "sync" "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" ) // External subscription fetching: a "subscription" external link is a remote @@ -42,26 +46,34 @@ var subscriptionCache = struct { inflight: make(map[string]*subscriptionFetch), } +// subscriptionFetchResult reports whether this caller performed the network +// fetch, so only it records status and cache hits stay read-only. +type subscriptionFetchResult struct { + links []string + fetched bool + err error +} + // fetchSubscriptionLinks returns the share links contained in a remote // subscription URL, using a short-lived cache. On any failure it returns the // last cached value (if present) or nil — never an error, so the rest of the // client's subscription still renders. -func fetchSubscriptionLinks(rawURL string) []string { +func fetchSubscriptionLinks(rawURL string) subscriptionFetchResult { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { - return nil + return subscriptionFetchResult{} } subscriptionCache.Lock() cached, ok := subscriptionCache.m[rawURL] if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL { subscriptionCache.Unlock() - return cached.links + return subscriptionFetchResult{links: cached.links} } if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting { subscriptionCache.Unlock() <-fetch.done - return fetch.links + return subscriptionFetchResult{links: fetch.links} } fetch := &subscriptionFetch{done: make(chan struct{})} subscriptionCache.inflight[rawURL] = fetch @@ -78,7 +90,7 @@ func fetchSubscriptionLinks(rawURL string) []string { if ok { fetch.links = cached.links } - return fetch.links + return subscriptionFetchResult{links: fetch.links, fetched: true, err: err} } subscriptionCache.Lock() @@ -86,7 +98,7 @@ func fetchSubscriptionLinks(rawURL string) []string { trimSubscriptionCacheLocked(rawURL) subscriptionCache.Unlock() fetch.links = links - return fetch.links + return subscriptionFetchResult{links: links, fetched: true} } func trimSubscriptionCacheLocked(keep string) { @@ -109,6 +121,28 @@ func trimSubscriptionCacheLocked(keep string) { } } +// recordExternalSubscriptionFetch stamps status on every row holding this URL, +// keyed by value because row ids churn on save and the cache is per URL. +func recordExternalSubscriptionFetch(rawURL string, fetchErr error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return + } + lastFetchError := "" + if fetchErr != nil { + lastFetchError = fetchErr.Error() + } + if err := database.GetDB(). + Model(&model.ClientExternalLink{}). + Where("kind = ? AND value = ?", model.ExternalLinkKindSubscription, rawURL). + Updates(map[string]any{ + "last_fetch_at": time.Now().UnixMilli(), + "last_fetch_error": lastFetchError, + }).Error; err != nil { + logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err) + } +} + func doFetchSubscriptionLinks(rawURL string) ([]string, error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) if err != nil { diff --git a/internal/sub/external_subscription_test.go b/internal/sub/external_subscription_test.go index 0a6c51b29..18de7a9d7 100644 --- a/internal/sub/external_subscription_test.go +++ b/internal/sub/external_subscription_test.go @@ -10,6 +10,9 @@ import ( "sync/atomic" "testing" "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func resetSubscriptionCache(t *testing.T) { @@ -44,7 +47,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) { var wg sync.WaitGroup for range callers { wg.Go(func() { - results <- fetchSubscriptionLinks(srv.URL) + results <- fetchSubscriptionLinks(srv.URL).links }) } @@ -73,7 +76,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) { defer srv.Close() for i := range subscriptionCacheCapacity + 1 { - links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i)) + links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i)).links if len(links) != 1 { t.Fatalf("links at %d = %#v", i, links) } @@ -122,12 +125,12 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T var wg sync.WaitGroup for range callers { wg.Go(func() { - results <- fetchSubscriptionLinks(staleURL) + results <- fetchSubscriptionLinks(staleURL).links }) } time.Sleep(100 * time.Millisecond) - if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://fresh@example.com:443" { + if links := fetchSubscriptionLinks(srv.URL + "/fresh").links; len(links) != 1 || links[0] != "vless://fresh@example.com:443" { t.Fatalf("fresh links = %#v", links) } close(release) @@ -178,3 +181,120 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) { t.Fatalf("links = %v, want [%q]", links, link) } } + +func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) { + initMutDB(t) + resetSubscriptionCache(t) + db := database.GetDB() + + var failing atomic.Bool + failing.Store(true) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if failing.Load() { + w.WriteHeader(http.StatusBadGateway) + return + } + _, _ = w.Write([]byte("vless://uuid@example.com:443#Node")) + })) + defer srv.Close() + + owners := []model.ClientRecord{ + {Email: "one@example.com", SubID: "sub-fetch", UUID: "uuid-1", Enable: true}, + {Email: "two@example.com", SubID: "sub-fetch", UUID: "uuid-2", Enable: true}, + } + for i := range owners { + if err := db.Create(&owners[i]).Error; err != nil { + t.Fatalf("seed client %d: %v", i, err) + } + row := model.ClientExternalLink{ + ClientId: owners[i].Id, + Kind: model.ExternalLinkKindSubscription, + Value: srv.URL, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed external link %d: %v", i, err) + } + } + + svc := NewSubService("") + entries, err := svc.getClientExternalLinksBySubId("sub-fetch") + if err != nil { + t.Fatalf("getClientExternalLinksBySubId: %v", err) + } + if len(entries) != 2 { + t.Fatalf("entries = %d, want 2", len(entries)) + } + + for _, e := range entries { + expandEntry(e) + } + + var rows []model.ClientExternalLink + if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil { + t.Fatalf("read rows: %v", err) + } + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + for _, row := range rows { + if row.LastFetchAt <= 0 { + t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt) + } + if row.LastFetchError != errBadStatus.Error() { + t.Fatalf("row %d lastFetchError = %q, want %q", row.Id, row.LastFetchError, errBadStatus) + } + } + + failing.Store(false) + resetSubscriptionCache(t) + for _, e := range entries { + expandEntry(e) + } + + if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil { + t.Fatalf("re-read rows: %v", err) + } + for _, row := range rows { + if row.LastFetchError != "" { + t.Fatalf("row %d lastFetchError = %q, want cleared after a good fetch", row.Id, row.LastFetchError) + } + if row.LastFetchAt <= 0 { + t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt) + } + } +} + +func TestExpandEntryCacheHitWritesNothing(t *testing.T) { + initMutDB(t) + resetSubscriptionCache(t) + db := database.GetDB() + + const subURL = "https://provider.example/cached" + rec := model.ClientRecord{Email: "cached@example.com", SubID: "sub-cached", UUID: "uuid", Enable: true} + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + row := model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindSubscription, Value: subURL} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed external link: %v", err) + } + + subscriptionCache.Lock() + subscriptionCache.m[subURL] = subscriptionCacheEntry{ + links: []string{"vless://uuid@example.com:443#Node"}, + fetchedAt: time.Now(), + } + subscriptionCache.Unlock() + + if got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindSubscription, Value: subURL}); len(got) != 1 { + t.Fatalf("expandEntry = %#v, want the cached link", got) + } + + var after model.ClientExternalLink + if err := db.First(&after, row.Id).Error; err != nil { + t.Fatalf("read row: %v", err) + } + if after.LastFetchAt != 0 || after.LastFetchError != "" { + t.Fatalf("cache hit wrote fetch status: %#v", after) + } +} diff --git a/internal/sub/mutation_audit_test.go b/internal/sub/mutation_audit_test.go index a9e23ea75..b5fe52946 100644 --- a/internal/sub/mutation_audit_test.go +++ b/internal/sub/mutation_audit_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -24,6 +25,10 @@ func initMutDB(t *testing.T) { t.Cleanup(func() { _ = database.CloseDB() }) } +func externalLinkEnabled(v bool) *bool { + return &v +} + // --- json_service.go:40 — rules are merged into routing only when non-empty --- func TestSubJsonService_CustomRulesPrepended(t *testing.T) { @@ -307,6 +312,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) { if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil { t.Fatalf("seed link a: %v", err) } + if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://disabled", Remark: "disabled", Enable: externalLinkEnabled(false), SortIndex: 3}).Error; err != nil { + t.Fatalf("seed disabled link: %v", err) + } + if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://expired", Remark: "expired", ExpiryTime: time.Now().Add(-time.Hour).UnixMilli(), SortIndex: 4}).Error; err != nil { + t.Fatalf("seed expired link: %v", err) + } out, err = s.getClientExternalLinksBySubId("sub-ok") if err != nil { diff --git a/internal/web/service/client_external_link.go b/internal/web/service/client_external_link.go index 0a03f65de..89ab35dc2 100644 --- a/internal/web/service/client_external_link.go +++ b/internal/web/service/client_external_link.go @@ -14,9 +14,12 @@ import ( // ExternalLinkInput is one row from the client form's Links tab. type ExternalLinkInput struct { - Kind string `json:"kind"` - Value string `json:"value"` - Remark string `json:"remark"` + Kind string `json:"kind"` + Value string `json:"value"` + Remark string `json:"remark"` + Enable *bool `json:"enable"` + ExpiryTime int64 `json:"expiryTime"` + NamePrefix string `json:"namePrefix"` } func (s *ClientService) GetExternalLinksForRecord(id int) ([]model.ClientExternalLink, error) { @@ -55,11 +58,21 @@ func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalL default: return nil, common.NewError("unknown external link kind: " + kind) } + if in.ExpiryTime < 0 { + return nil, common.NewError("external link expiryTime must be 0 (never) or a future unix millisecond timestamp: " + value) + } + enable := true + if in.Enable != nil { + enable = *in.Enable + } out = append(out, model.ClientExternalLink{ - Kind: kind, - Value: value, - Remark: strings.TrimSpace(in.Remark), - SortIndex: len(out), + Kind: kind, + Value: value, + Remark: strings.TrimSpace(in.Remark), + Enable: &enable, + ExpiryTime: in.ExpiryTime, + NamePrefix: in.NamePrefix, + SortIndex: len(out), }) } return out, nil @@ -78,10 +91,25 @@ func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkI } db := database.GetDB() return db.Transaction(func(tx *gorm.DB) error { + var existing []model.ClientExternalLink + if err := tx.Where("client_id = ?", id).Find(&existing).Error; err != nil { + return err + } + byKindValue := make(map[string]model.ClientExternalLink, len(existing)) + for _, row := range existing { + key := row.Kind + "\x00" + row.Value + if _, ok := byKindValue[key]; !ok { + byKindValue[key] = row + } + } if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil { return err } for i := range rows { + if old, ok := byKindValue[rows[i].Kind+"\x00"+rows[i].Value]; ok { + rows[i].LastFetchAt = old.LastFetchAt + rows[i].LastFetchError = old.LastFetchError + } rows[i].ClientId = id if err := tx.Create(&rows[i]).Error; err != nil { return err diff --git a/internal/web/service/client_external_link_test.go b/internal/web/service/client_external_link_test.go new file mode 100644 index 000000000..814079e9c --- /dev/null +++ b/internal/web/service/client_external_link_test.go @@ -0,0 +1,124 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func externalLinkBool(v bool) *bool { + return &v +} + +func TestSetExternalLinksPersistsEnableState(t *testing.T) { + setupBulkDB(t) + db := database.GetDB() + svc := &ClientService{} + + rec := model.ClientRecord{Email: "links@example.com", SubID: "sub-links", UUID: "uuid", Enable: true} + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("create client: %v", err) + } + + if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{ + {Kind: model.ExternalLinkKindLink, Value: "trojan://pw@example.com:443#on", Remark: "Primary", Enable: externalLinkBool(true), ExpiryTime: 1767225600000}, + {Kind: model.ExternalLinkKindSubscription, Value: "https://provider.example/sub", Remark: "Provider", Enable: externalLinkBool(false), NamePrefix: "[zjh] "}, + {Kind: model.ExternalLinkKindLink, Value: "trojan://pw@example.net:443#default"}, + }); err != nil { + t.Fatalf("set external links: %v", err) + } + + rows, err := svc.GetExternalLinksForRecord(rec.Id) + if err != nil { + t.Fatalf("get external links: %v", err) + } + if len(rows) != 3 { + t.Fatalf("rows = %d, want 3", len(rows)) + } + if rows[0].Enable == nil || *rows[0].Enable != true { + t.Fatalf("first row enable = %#v, want true", rows[0].Enable) + } + if rows[1].Enable == nil || *rows[1].Enable != false { + t.Fatalf("second row enable = %#v, want false", rows[1].Enable) + } + if rows[2].Enable == nil || *rows[2].Enable != true { + t.Fatalf("omitted enable should default true, got %#v", rows[2].Enable) + } + if rows[0].Remark != "Primary" || rows[0].ExpiryTime != 1767225600000 { + t.Fatalf("first row fields not persisted: %#v", rows[0]) + } + if rows[1].Remark != "Provider" || rows[1].NamePrefix != "[zjh] " { + t.Fatalf("subscription fields not persisted: %#v", rows[1]) + } +} + +func TestSetExternalLinksPreservesFetchStatus(t *testing.T) { + setupBulkDB(t) + db := database.GetDB() + svc := &ClientService{} + + rec := model.ClientRecord{Email: "status@example.com", SubID: "sub-status", UUID: "uuid", Enable: true} + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("create client: %v", err) + } + row := model.ClientExternalLink{ + ClientId: rec.Id, + Kind: model.ExternalLinkKindSubscription, + Value: "https://provider.example/sub", + Remark: "old", + LastFetchAt: 1767220000000, + LastFetchError: "timeout", + SortIndex: 0, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create external link: %v", err) + } + + if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{ + {Kind: row.Kind, Value: row.Value, Remark: "new", Enable: externalLinkBool(true)}, + }); err != nil { + t.Fatalf("set external links: %v", err) + } + + rows, err := svc.GetExternalLinksForRecord(rec.Id) + if err != nil { + t.Fatalf("get external links: %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].LastFetchAt != row.LastFetchAt || rows[0].LastFetchError != row.LastFetchError { + t.Fatalf("fetch status not preserved: %#v", rows[0]) + } + if rows[0].Remark != "new" { + t.Fatalf("editable fields not updated: %#v", rows[0]) + } +} + +func TestSetExternalLinksRejectsNegativeExpiry(t *testing.T) { + setupBulkDB(t) + db := database.GetDB() + svc := &ClientService{} + + rec := model.ClientRecord{Email: "negative@example.com", SubID: "sub-negative", UUID: "uuid", Enable: true} + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("create client: %v", err) + } + + err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{ + {Kind: model.ExternalLinkKindLink, Value: "trojan://pw@example.com:443#neg", ExpiryTime: -86400000}, + }) + want := "external link expiryTime must be 0 (never) or a future unix millisecond timestamp: trojan://pw@example.com:443#neg\n" + if err == nil || err.Error() != want { + t.Fatalf("err = %v, want %q", err, want) + } + + rows, err := svc.GetExternalLinksForRecord(rec.Id) + if err != nil { + t.Fatalf("get external links: %v", err) + } + if len(rows) != 0 { + t.Fatalf("rows = %d, want the rejected save to persist nothing", len(rows)) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index b0c9e5d30..0a6319349 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -693,6 +693,10 @@ "addExternalSubscription": "إضافة اشتراك خارجي", "noExternalLinks": "لا توجد روابط خارجية بعد.", "noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.", + "namePrefix": "بادئة الاسم", + "lastFetchAt": "آخر جلب", + "lastFetchError": "خطأ في الجلب", + "neverFetched": "لم يتم الجلب بعد", "submitEdit": "حفظ التغييرات", "clientCount": "عدد العملاء", "bulk": "إضافة مجمعة", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index e46003952..6459063c6 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Add External Subscription", "noExternalLinks": "No external links yet.", "noExternalSubscriptions": "No external subscriptions yet.", + "namePrefix": "Name prefix", + "lastFetchAt": "Last fetch", + "lastFetchError": "Fetch error", + "neverFetched": "Not fetched yet", "submitEdit": "Save Changes", "clientCount": "Number of Clients", "bulk": "Add Bulk", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index df18acdd9..2c1bae851 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Añadir suscripción externa", "noExternalLinks": "Aún no hay enlaces externos.", "noExternalSubscriptions": "Aún no hay suscripciones externas.", + "namePrefix": "Prefijo de nombre", + "lastFetchAt": "Última obtención", + "lastFetchError": "Error de obtención", + "neverFetched": "Aún no obtenido", "submitEdit": "Guardar cambios", "clientCount": "Número de clientes", "bulk": "Añadir en lote", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 50b51b85b..3a531ed1e 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -693,6 +693,10 @@ "addExternalSubscription": "افزودن سابسکریپشن خارجی", "noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.", "noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.", + "namePrefix": "پیشوند نام", + "lastFetchAt": "آخرین دریافت", + "lastFetchError": "خطای دریافت", + "neverFetched": "هنوز دریافت نشده", "submitEdit": "ذخیره تغییرات", "clientCount": "تعداد کلاینت‌ها", "bulk": "افزودن گروهی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index d2d1ebeac..6346a12dc 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Tambah Langganan Eksternal", "noExternalLinks": "Belum ada tautan eksternal.", "noExternalSubscriptions": "Belum ada langganan eksternal.", + "namePrefix": "Awalan nama", + "lastFetchAt": "Pengambilan terakhir", + "lastFetchError": "Galat pengambilan", + "neverFetched": "Belum diambil", "submitEdit": "Simpan perubahan", "clientCount": "Jumlah klien", "bulk": "Tambah massal", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index e23d84fbc..b78076be5 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -693,6 +693,10 @@ "addExternalSubscription": "外部サブスクリプションを追加", "noExternalLinks": "外部リンクはまだありません。", "noExternalSubscriptions": "外部サブスクリプションはまだありません。", + "namePrefix": "名前の接頭辞", + "lastFetchAt": "最終取得", + "lastFetchError": "取得エラー", + "neverFetched": "未取得", "submitEdit": "変更を保存", "clientCount": "クライアント数", "bulk": "一括追加", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 26d3f6424..307f23041 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Adicionar assinatura externa", "noExternalLinks": "Ainda não há links externos.", "noExternalSubscriptions": "Ainda não há assinaturas externas.", + "namePrefix": "Prefixo do nome", + "lastFetchAt": "Última busca", + "lastFetchError": "Erro na busca", + "neverFetched": "Ainda não buscado", "submitEdit": "Salvar alterações", "clientCount": "Número de clientes", "bulk": "Adicionar em lote", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ad5ad49c8..a53d1c776 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Добавить внешнюю подписку", "noExternalLinks": "Пока нет внешних ссылок.", "noExternalSubscriptions": "Пока нет внешних подписок.", + "namePrefix": "Префикс имени", + "lastFetchAt": "Последнее обновление", + "lastFetchError": "Ошибка обновления", + "neverFetched": "Ещё не загружено", "submitEdit": "Сохранить изменения", "clientCount": "Количество клиентов", "bulk": "Массовое добавление", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index ca9cfb661..6c0dbafaf 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Harici Abonelik Ekle", "noExternalLinks": "Henüz harici bağlantı yok.", "noExternalSubscriptions": "Henüz harici abonelik yok.", + "namePrefix": "Ad öneki", + "lastFetchAt": "Son çekme", + "lastFetchError": "Çekme hatası", + "neverFetched": "Henüz çekilmedi", "submitEdit": "Değişiklikleri Kaydet", "clientCount": "Kullanıcı Sayısı", "bulk": "Toplu Ekle", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 7f37d8dd6..804cdd541 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Додати зовнішню підписку", "noExternalLinks": "Зовнішніх посилань ще немає.", "noExternalSubscriptions": "Зовнішніх підписок ще немає.", + "namePrefix": "Префікс імені", + "lastFetchAt": "Останнє оновлення", + "lastFetchError": "Помилка оновлення", + "neverFetched": "Ще не завантажено", "submitEdit": "Зберегти зміни", "clientCount": "Кількість клієнтів", "bulk": "Масове додавання", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 235f2a8ff..6c0f13cb1 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -693,6 +693,10 @@ "addExternalSubscription": "Thêm đăng ký ngoài", "noExternalLinks": "Chưa có liên kết ngoài.", "noExternalSubscriptions": "Chưa có đăng ký ngoài.", + "namePrefix": "Tiền tố tên", + "lastFetchAt": "Lần tải gần nhất", + "lastFetchError": "Lỗi tải", + "neverFetched": "Chưa tải", "submitEdit": "Lưu thay đổi", "clientCount": "Số lượng khách hàng", "bulk": "Thêm hàng loạt", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index a15c49a12..b7abc7c6e 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -693,6 +693,10 @@ "addExternalSubscription": "添加外部订阅", "noExternalLinks": "暂无外部链接。", "noExternalSubscriptions": "暂无外部订阅。", + "namePrefix": "名称前缀", + "lastFetchAt": "最后拉取", + "lastFetchError": "拉取失败", + "neverFetched": "尚未拉取", "submitEdit": "保存更改", "clientCount": "客户端数量", "bulk": "批量添加", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 68371af75..d02c40af5 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -693,6 +693,10 @@ "addExternalSubscription": "新增外部訂閱", "noExternalLinks": "尚無外部連結。", "noExternalSubscriptions": "尚無外部訂閱。", + "namePrefix": "名稱前綴", + "lastFetchAt": "最後拉取", + "lastFetchError": "拉取失敗", + "neverFetched": "尚未拉取", "submitEdit": "儲存變更", "clientCount": "客戶端數量", "bulk": "批次新增",