updated accounts page
This commit is contained in:
parent
d7fd75e487
commit
030f859ba3
9 changed files with 221 additions and 48 deletions
|
|
@ -281,18 +281,31 @@ func (s *readStore) StickerSetDocumentIDs(ctx context.Context, setID int64) ([]s
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, error) {
|
// SearchAccounts matches by exact id, phone prefix (uses users_phone_prefix_idx),
|
||||||
|
// or substring on username/display name (uses the users_username_lower_trgm_idx
|
||||||
|
// and users_name_lower_trgm_idx GIN indexes -- those existed unused by this
|
||||||
|
// query before; exact-only matching made "search" effectively useless for
|
||||||
|
// anything but a fully-known id/phone/username). Keyset-paginated exactly
|
||||||
|
// like ListAccounts so results beyond the page limit are reachable instead of
|
||||||
|
// being silently dropped.
|
||||||
|
func (s *readStore) SearchAccounts(ctx context.Context, q string, beforeActiveUS, beforeID int64, limit int) ([]AccountRow, bool, error) {
|
||||||
q = strings.TrimSpace(q)
|
q = strings.TrimSpace(q)
|
||||||
if q == "" {
|
if q == "" {
|
||||||
return nil, nil
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = accountListDefaultLimit
|
||||||
|
}
|
||||||
|
if limit > accountListMaxLimit {
|
||||||
|
limit = accountListMaxLimit
|
||||||
}
|
}
|
||||||
id := int64(-1)
|
id := int64(-1)
|
||||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||||
id = n
|
id = n
|
||||||
}
|
}
|
||||||
phone := strings.TrimPrefix(strings.ReplaceAll(q, " ", ""), "+")
|
phonePrefix := strings.TrimPrefix(strings.ReplaceAll(q, " ", ""), "+") + "%"
|
||||||
phoneRaw := strings.TrimSpace(q)
|
term := strings.ToLower(strings.TrimPrefix(q, "@"))
|
||||||
username := strings.ToLower(strings.TrimPrefix(q, "@"))
|
substring := "%" + term + "%"
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
WITH auth AS (
|
WITH auth AS (
|
||||||
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
|
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
|
||||||
|
|
@ -310,22 +323,83 @@ LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||||
LEFT JOIN auth a ON a.user_id = u.id
|
LEFT JOIN auth a ON a.user_id = u.id
|
||||||
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||||
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
|
WHERE NOT u.is_bot
|
||||||
ORDER BY u.id
|
AND (
|
||||||
LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
|
u.id = $1
|
||||||
|
OR u.phone LIKE $2
|
||||||
|
OR lower((u.username)::text) LIKE $3
|
||||||
|
OR p.username_lower = $4
|
||||||
|
OR lower(TRIM(BOTH FROM ((u.first_name)::text || ' '::text || (u.last_name)::text))) LIKE $3
|
||||||
|
)
|
||||||
|
AND ($5::bigint = 0 OR (COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($5::double precision) / 1000000.0), $6::bigint))
|
||||||
|
ORDER BY COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC
|
||||||
|
LIMIT $7`, id, phonePrefix, substring, term, beforeActiveUS, beforeID, limit+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("search accounts: %w", err)
|
return nil, false, fmt.Errorf("search accounts: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := make([]AccountRow, 0)
|
out := make([]AccountRow, 0, limit+1)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item AccountRow
|
var item AccountRow
|
||||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
|
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
hasMore := len(out) > limit
|
||||||
|
if hasMore {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemAccountIDs excludes telesrv's built-in accounts (domain.SystemUserByID)
|
||||||
|
// from counts meant to reflect real end users. BotFather/Stickers/ChatBot are
|
||||||
|
// already is_bot=true and excluded that way, but the official 777000 service
|
||||||
|
// account is deliberately NOT flagged is_bot (see domain.OfficialSystemUser)
|
||||||
|
// so it still needs an explicit exclusion here.
|
||||||
|
var systemAccountIDs = []int64{
|
||||||
|
domain.OfficialSystemUserID,
|
||||||
|
domain.BotFatherUserID,
|
||||||
|
domain.StickersBotUserID,
|
||||||
|
domain.ChatBotUserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAccounts returns the total number of real (non-bot, non-system) user accounts.
|
||||||
|
func (s *readStore) CountAccounts(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM users WHERE NOT is_bot AND id <> ALL($1::bigint[])`,
|
||||||
|
systemAccountIDs).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("count accounts: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountOnlineAccounts counts accounts the live server currently considers
|
||||||
|
// online. Presence itself lives only in the live server's in-process
|
||||||
|
// presenceTracker (internal/rpc/presence.go), unreachable from this
|
||||||
|
// out-of-process admin panel, but that tracker persists users.last_seen_at
|
||||||
|
// continuously (at most every lastSeenPersistDebounce=25s) while a session
|
||||||
|
// stays online -- unlike authorizations.active_at, which is only touched at
|
||||||
|
// new-session/layer-negotiation time and goes stale for a long-lived
|
||||||
|
// connection. last_seen_at within the same userOnlineTTL=5m window the
|
||||||
|
// server itself uses is therefore the closest DB-side proxy available.
|
||||||
|
func (s *readStore) CountOnlineAccounts(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*)
|
||||||
|
FROM users
|
||||||
|
WHERE NOT is_bot
|
||||||
|
AND id <> ALL($1::bigint[])
|
||||||
|
AND last_seen_at > extract(epoch FROM now() - interval '5 minutes')::bigint`,
|
||||||
|
systemAccountIDs).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("count online accounts: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type BotRow struct {
|
type BotRow struct {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ func (s *server) routes() http.Handler {
|
||||||
mux.HandleFunc("POST /api/logout", s.handleAPILogout)
|
mux.HandleFunc("POST /api/logout", s.handleAPILogout)
|
||||||
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
|
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
|
||||||
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||||
|
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
|
||||||
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||||
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||||
|
|
@ -409,7 +410,7 @@ func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
hasMore := false
|
hasMore := false
|
||||||
var err error
|
var err error
|
||||||
if strings.TrimSpace(q) != "" {
|
if strings.TrimSpace(q) != "" {
|
||||||
rows, err = s.read.SearchAccounts(r.Context(), q)
|
rows, hasMore, err = s.read.SearchAccounts(r.Context(), q, beforeActiveUS, beforeID, limit)
|
||||||
} else {
|
} else {
|
||||||
rows, hasMore, err = s.read.ListAccounts(r.Context(), beforeActiveUS, beforeID, limit)
|
rows, hasMore, err = s.read.ListAccounts(r.Context(), beforeActiveUS, beforeID, limit)
|
||||||
}
|
}
|
||||||
|
|
@ -441,6 +442,27 @@ func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.read == nil {
|
||||||
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := s.read.CountAccounts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
online, err := s.read.CountOnlineAccounts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"total": total,
|
||||||
|
"online": online,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-SClW8slv.js"></script>
|
<script type="module" crossorigin src="/assets/index-kK52bvQu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DjPaQXsn.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DjPaQXsn.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
AccountDetail,
|
AccountDetail,
|
||||||
AccountListResponse,
|
AccountListResponse,
|
||||||
|
AccountStatsResponse,
|
||||||
BotDetail,
|
BotDetail,
|
||||||
BotListResponse,
|
BotListResponse,
|
||||||
ChannelDetail,
|
ChannelDetail,
|
||||||
|
|
@ -58,6 +59,7 @@ export const api = {
|
||||||
}),
|
}),
|
||||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
||||||
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
||||||
|
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
|
||||||
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
||||||
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
||||||
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,13 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"common.members": "Members",
|
"common.members": "Members",
|
||||||
"common.messageId": "Message ID",
|
"common.messageId": "Message ID",
|
||||||
"common.name": "Name",
|
"common.name": "Name",
|
||||||
|
"common.next": "Next page",
|
||||||
"common.no": "No",
|
"common.no": "No",
|
||||||
"common.noResults": "No results",
|
"common.noResults": "No results",
|
||||||
"common.none": "None",
|
"common.none": "None",
|
||||||
"common.normal": "Normal",
|
"common.normal": "Normal",
|
||||||
"common.operations": "Operations",
|
"common.operations": "Operations",
|
||||||
|
"common.previous": "Previous page",
|
||||||
"common.owner": "Owner",
|
"common.owner": "Owner",
|
||||||
"common.platform": "Platform",
|
"common.platform": "Platform",
|
||||||
"common.refresh": "Refresh",
|
"common.refresh": "Refresh",
|
||||||
|
|
@ -113,6 +115,8 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"account.recentActive": "Recently active accounts",
|
"account.recentActive": "Recently active accounts",
|
||||||
"account.currentPage": "Accounts on page",
|
"account.currentPage": "Accounts on page",
|
||||||
"account.onlineDevices": "Online device records",
|
"account.onlineDevices": "Online device records",
|
||||||
|
"account.totalUsers": "Total users",
|
||||||
|
"account.onlineNow": "Online now",
|
||||||
"account.premium": "Premium",
|
"account.premium": "Premium",
|
||||||
"account.frozen": "Frozen",
|
"account.frozen": "Frozen",
|
||||||
"account.searchPlaceholder": "User ID / phone / username",
|
"account.searchPlaceholder": "User ID / phone / username",
|
||||||
|
|
@ -582,11 +586,13 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"common.members": "成员",
|
"common.members": "成员",
|
||||||
"common.messageId": "消息 ID",
|
"common.messageId": "消息 ID",
|
||||||
"common.name": "姓名",
|
"common.name": "姓名",
|
||||||
|
"common.next": "下一页",
|
||||||
"common.no": "否",
|
"common.no": "否",
|
||||||
"common.noResults": "无结果",
|
"common.noResults": "无结果",
|
||||||
"common.none": "无",
|
"common.none": "无",
|
||||||
"common.normal": "正常",
|
"common.normal": "正常",
|
||||||
"common.operations": "操作",
|
"common.operations": "操作",
|
||||||
|
"common.previous": "上一页",
|
||||||
"common.owner": "所属",
|
"common.owner": "所属",
|
||||||
"common.platform": "平台",
|
"common.platform": "平台",
|
||||||
"common.refresh": "刷新",
|
"common.refresh": "刷新",
|
||||||
|
|
@ -662,6 +668,8 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"account.recentActive": "最近活跃账号",
|
"account.recentActive": "最近活跃账号",
|
||||||
"account.currentPage": "当前页账号",
|
"account.currentPage": "当前页账号",
|
||||||
"account.onlineDevices": "在线设备记录",
|
"account.onlineDevices": "在线设备记录",
|
||||||
|
"account.totalUsers": "用户总数",
|
||||||
|
"account.onlineNow": "当前在线",
|
||||||
"account.premium": "会员",
|
"account.premium": "会员",
|
||||||
"account.frozen": "冻结",
|
"account.frozen": "冻结",
|
||||||
"account.searchPlaceholder": "用户 ID / 手机号 / 用户名",
|
"account.searchPlaceholder": "用户 ID / 手机号 / 用户名",
|
||||||
|
|
@ -1064,11 +1072,13 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"common.members": "Участники",
|
"common.members": "Участники",
|
||||||
"common.messageId": "ID сообщения",
|
"common.messageId": "ID сообщения",
|
||||||
"common.name": "Имя",
|
"common.name": "Имя",
|
||||||
|
"common.next": "Следующая страница",
|
||||||
"common.no": "Нет",
|
"common.no": "Нет",
|
||||||
"common.noResults": "Нет результатов",
|
"common.noResults": "Нет результатов",
|
||||||
"common.none": "Нет",
|
"common.none": "Нет",
|
||||||
"common.normal": "Обычный",
|
"common.normal": "Обычный",
|
||||||
"common.operations": "Операции",
|
"common.operations": "Операции",
|
||||||
|
"common.previous": "Предыдущая страница",
|
||||||
"common.owner": "Владелец",
|
"common.owner": "Владелец",
|
||||||
"common.platform": "Платформа",
|
"common.platform": "Платформа",
|
||||||
"common.refresh": "Обновить",
|
"common.refresh": "Обновить",
|
||||||
|
|
@ -1144,6 +1154,8 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"account.recentActive": "Недавно активные аккаунты",
|
"account.recentActive": "Недавно активные аккаунты",
|
||||||
"account.currentPage": "Аккаунты на странице",
|
"account.currentPage": "Аккаунты на странице",
|
||||||
"account.onlineDevices": "Активные сессии устройств",
|
"account.onlineDevices": "Активные сессии устройств",
|
||||||
|
"account.totalUsers": "Всего пользователей",
|
||||||
|
"account.onlineNow": "Сейчас онлайн",
|
||||||
"account.premium": "Premium",
|
"account.premium": "Premium",
|
||||||
"account.frozen": "Заморожен",
|
"account.frozen": "Заморожен",
|
||||||
"account.searchPlaceholder": "ID пользователя / телефон / имя пользователя",
|
"account.searchPlaceholder": "ID пользователя / телефон / имя пользователя",
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ export function accountMetrics(rows: AccountRow[]) {
|
||||||
return rows.reduce(
|
return rows.reduce(
|
||||||
(acc, row) => {
|
(acc, row) => {
|
||||||
acc.devices += row.DeviceCount;
|
acc.devices += row.DeviceCount;
|
||||||
if (row.PremiumUntil > 0) acc.premium += 1;
|
|
||||||
if (row.Frozen) acc.frozen += 1;
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{ devices: 0, premium: 0, frozen: 0 }
|
{ devices: 0 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
|
@ -8,82 +8,142 @@ import { useI18n } from "../i18n";
|
||||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||||
import { accountMetrics } from "../lib/metrics";
|
import { accountMetrics } from "../lib/metrics";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { AccountListResponse } from "../types";
|
import type { AccountListResponse, AccountStatsResponse } from "../types";
|
||||||
|
|
||||||
|
type Cursor = { beforeID: number; beforeActiveUS: number };
|
||||||
|
type AccountPageSize = 10 | 20 | 50 | 100;
|
||||||
|
|
||||||
|
const zeroCursor: Cursor = { beforeID: 0, beforeActiveUS: 0 };
|
||||||
|
|
||||||
export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [limit, setLimit] = useState("50");
|
const [limit, setLimit] = useState<AccountPageSize>(50);
|
||||||
const [data, setData] = useState<AccountListResponse | null>(null);
|
const [data, setData] = useState<AccountListResponse | null>(null);
|
||||||
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
|
const [stats, setStats] = useState<AccountStatsResponse | null>(null);
|
||||||
|
// history holds the cursor used to reach every page before the current
|
||||||
|
// one, so "Previous" can pop back without re-deriving offsets -- keyset
|
||||||
|
// pagination has no notion of "page N" to jump back to otherwise.
|
||||||
|
const [history, setHistory] = useState<Cursor[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
async function load(next = false) {
|
async function fetchPage(query: string, at: Cursor) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
const params = new URLSearchParams({ limit });
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
if (q.trim()) {
|
if (query.trim()) {
|
||||||
params.set("q", q.trim());
|
params.set("q", query.trim());
|
||||||
} else if (next) {
|
}
|
||||||
params.set("before_id", String(cursor.beforeID));
|
if (at.beforeID || at.beforeActiveUS) {
|
||||||
params.set("before_active_us", String(cursor.beforeActiveUS));
|
params.set("before_id", String(at.beforeID));
|
||||||
|
params.set("before_active_us", String(at.beforeActiveUS));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await api.accounts(params);
|
const result = await api.accounts(params);
|
||||||
setData(result);
|
setData(result);
|
||||||
setCursor({
|
return result;
|
||||||
beforeID: result.next_before_id,
|
|
||||||
beforeActiveUS: result.next_before_active_us
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFresh() {
|
||||||
|
setHistory([]);
|
||||||
|
setCursor(zeroCursor);
|
||||||
|
await fetchPage(q, zeroCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNext() {
|
||||||
|
if (!data?.has_more) return;
|
||||||
|
const at = { beforeID: data.next_before_id, beforeActiveUS: data.next_before_active_us };
|
||||||
|
const result = await fetchPage(q, at);
|
||||||
|
if (result) {
|
||||||
|
setHistory((prev) => [...prev, cursor]);
|
||||||
|
setCursor(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrev() {
|
||||||
|
if (history.length === 0) return;
|
||||||
|
const at = history[history.length - 1];
|
||||||
|
const result = await fetchPage(q, at);
|
||||||
|
if (result) {
|
||||||
|
setHistory((prev) => prev.slice(0, -1));
|
||||||
|
setCursor(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
setStats(await api.accountStats());
|
||||||
|
} catch {
|
||||||
|
// Stats are a header nicety; a failure here shouldn't block the list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load(false);
|
void loadFresh();
|
||||||
|
void loadStats();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const metrics = accountMetrics(data?.rows ?? []);
|
const metrics = accountMetrics(data?.rows ?? []);
|
||||||
|
const canGoPrev = history.length > 0 && !busy;
|
||||||
|
const canGoNext = Boolean(data?.has_more) && !busy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title={t("account.pageTitle")}
|
title={t("account.pageTitle")}
|
||||||
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
|
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
|
||||||
actions={
|
actions={
|
||||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
<button
|
||||||
|
className="btn"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void loadFresh();
|
||||||
|
void loadStats();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
<RefreshCw size={15} /> {t("common.refresh")}
|
<RefreshCw size={15} /> {t("common.refresh")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<div className="metric-row">
|
<div className="metric-row">
|
||||||
<Metric label={t("account.currentPage")} value={String(data?.rows.length ?? 0)} />
|
<Metric label={t("account.totalUsers")} value={stats ? String(stats.total) : "…"} />
|
||||||
|
<Metric label={t("account.onlineNow")} value={stats ? String(stats.online) : "…"} tone="good" />
|
||||||
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
|
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
|
||||||
<Metric label={t("account.premium")} value={String(metrics.premium)} tone="good" />
|
|
||||||
<Metric label={t("account.frozen")} value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
|
|
||||||
</div>
|
</div>
|
||||||
<QueryPanel>
|
<QueryPanel>
|
||||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
|
||||||
<label className="searchbox">
|
<label className="searchbox">
|
||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} />
|
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field-inline">
|
<label className="gift-page-size">
|
||||||
<span>{t("common.limit")}</span>
|
<span>{t("common.limit")}</span>
|
||||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as AccountPageSize)}>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||||
</button>
|
</button>
|
||||||
{data?.listing && data.has_more && (
|
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
|
||||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
<ChevronLeft size={15} /> {t("common.previous")}
|
||||||
<ChevronRight size={15} /> {t("messages.nextPage")}
|
</button>
|
||||||
|
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
|
||||||
|
<ChevronRight size={15} /> {t("common.next")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,11 @@ export type AccountListResponse = {
|
||||||
listing: boolean;
|
listing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AccountStatsResponse = {
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChannelListResponse = {
|
export type ChannelListResponse = {
|
||||||
query: string;
|
query: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue