chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
118
cmd/telesrv-admin/main.go
Normal file
118
cmd/telesrv-admin/main.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pool, err := pgxpool.New(ctx, cfg.PostgresDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect postgres: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
srv, err := newServer(cfg, newReadStore(pool))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: srv.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(shutdownCtx)
|
||||
}()
|
||||
log.Printf("telesrv-admin listening on %s", cfg.Addr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type uiConfig struct {
|
||||
Addr string
|
||||
PostgresDSN string
|
||||
AdminAPIURL string
|
||||
AdminAPIToken string
|
||||
Password string
|
||||
Token string
|
||||
SessionKey []byte
|
||||
}
|
||||
|
||||
func loadConfig() (uiConfig, error) {
|
||||
cfg := uiConfig{
|
||||
Addr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"),
|
||||
PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable"),
|
||||
AdminAPIURL: adminAPIURL(envOr("TELESRV_ADMIN_API_ADDR", "127.0.0.1:2399")),
|
||||
AdminAPIToken: os.Getenv("TELESRV_ADMIN_API_TOKEN"),
|
||||
Password: os.Getenv("TELESRV_ADMIN_UI_PASSWORD"),
|
||||
Token: os.Getenv("TELESRV_ADMIN_UI_TOKEN"),
|
||||
}
|
||||
if cfg.Password == "" && cfg.Token == "" {
|
||||
return cfg, fmt.Errorf("TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AdminAPIToken) == "" {
|
||||
return cfg, fmt.Errorf("TELESRV_ADMIN_API_TOKEN is required for admin write actions")
|
||||
}
|
||||
rawKey := os.Getenv("TELESRV_ADMIN_SESSION_KEY")
|
||||
if rawKey == "" {
|
||||
return cfg, fmt.Errorf("TELESRV_ADMIN_SESSION_KEY is required")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(rawKey))
|
||||
cfg.SessionKey = sum[:]
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func adminAPIURL(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:2399"
|
||||
}
|
||||
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
|
||||
return strings.TrimRight(addr, "/")
|
||||
}
|
||||
return "http://" + addr
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func newCommandID(prefix string) string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return prefix + "-" + time.Now().UTC().Format("20060102T150405.000000000") + "-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
766
cmd/telesrv-admin/readstore.go
Normal file
766
cmd/telesrv-admin/readstore.go
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
accountSearchLimit = 20
|
||||
accountListDefaultLimit = 50
|
||||
accountListMaxLimit = 100
|
||||
channelSearchLimit = 50
|
||||
channelListDefaultLimit = 50
|
||||
channelListMaxLimit = 100
|
||||
messagePageLimit = 100
|
||||
)
|
||||
|
||||
type readStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func newReadStore(pool *pgxpool.Pool) *readStore {
|
||||
return &readStore{pool: pool}
|
||||
}
|
||||
|
||||
type AccountRow struct {
|
||||
ID int64
|
||||
Phone string
|
||||
Username string
|
||||
FirstName string
|
||||
LastName string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Frozen bool
|
||||
Reason string
|
||||
Verified bool
|
||||
PremiumUntil int64
|
||||
LastActiveAt time.Time
|
||||
DeviceCount int
|
||||
}
|
||||
|
||||
type AccountDetail struct {
|
||||
Account AccountRow
|
||||
About string
|
||||
LastSeenAt int64
|
||||
Verified bool
|
||||
Support bool
|
||||
Bot bool
|
||||
Restriction RestrictionRow
|
||||
HasRestriction bool
|
||||
Authorizations []AuthorizationRow
|
||||
AuditLogs []AuditLogRow
|
||||
}
|
||||
|
||||
type RestrictionRow struct {
|
||||
Frozen bool
|
||||
Reason string
|
||||
Actor string
|
||||
CommandID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AuthorizationRow struct {
|
||||
AuthKeyID int64
|
||||
Hash int64
|
||||
Layer int
|
||||
DeviceModel string
|
||||
Platform string
|
||||
SystemVersion string
|
||||
APIID int
|
||||
AppVersion string
|
||||
IP string
|
||||
PasswordPending bool
|
||||
CreatedAt time.Time
|
||||
ActiveAt time.Time
|
||||
}
|
||||
|
||||
type AuditLogRow struct {
|
||||
ID int64
|
||||
CommandID string
|
||||
Actor string
|
||||
Action string
|
||||
DryRun bool
|
||||
Reason string
|
||||
Status string
|
||||
Error string
|
||||
Result string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ChannelRow struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
CreatorUserID int64
|
||||
Title string
|
||||
About string
|
||||
Username string
|
||||
Broadcast bool
|
||||
Megagroup bool
|
||||
Forum bool
|
||||
Monoforum bool
|
||||
Verified bool
|
||||
Deleted bool
|
||||
ParticipantsCount int
|
||||
AdminsCount int
|
||||
KickedCount int
|
||||
BannedCount int
|
||||
TopMessageID int
|
||||
PinnedMessageID int
|
||||
PTS int
|
||||
Date int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ChannelDetail struct {
|
||||
Channel ChannelRow
|
||||
ChannelJSON string
|
||||
AuditLogs []AuditLogRow
|
||||
}
|
||||
|
||||
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id := int64(-1)
|
||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
id = n
|
||||
}
|
||||
phone := strings.TrimPrefix(strings.ReplaceAll(q, " ", ""), "+")
|
||||
phoneRaw := strings.TrimSpace(q)
|
||||
username := strings.ToLower(strings.TrimPrefix(q, "@"))
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH auth AS (
|
||||
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
|
||||
FROM authorizations
|
||||
GROUP BY user_id
|
||||
)
|
||||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
FROM users u
|
||||
LEFT JOIN account_send_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 auth a ON a.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
|
||||
ORDER BY u.id
|
||||
LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AccountRow, 0)
|
||||
for rows.Next() {
|
||||
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.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id := int64(-1)
|
||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
id = n
|
||||
}
|
||||
username := strings.ToLower(strings.TrimPrefix(q, "@"))
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
|
||||
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
|
||||
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
|
||||
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
|
||||
WHERE NOT c.deleted
|
||||
AND NOT c.monoforum
|
||||
AND (c.broadcast OR c.megagroup)
|
||||
AND (
|
||||
c.id = $1
|
||||
OR lower(COALESCE(c.username, '')) = $2
|
||||
OR p.username_lower = $2
|
||||
OR lower(c.title) LIKE '%' || $2 || '%'
|
||||
)
|
||||
ORDER BY c.updated_at DESC, c.id DESC
|
||||
LIMIT $3`, id, username, channelSearchLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search channels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanChannelRows(rows)
|
||||
}
|
||||
|
||||
func (s *readStore) ListChannels(ctx context.Context, beforeUpdatedUS, beforeID int64, limit int) ([]ChannelRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = channelListDefaultLimit
|
||||
}
|
||||
if limit > channelListMaxLimit {
|
||||
limit = channelListMaxLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
|
||||
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
|
||||
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
|
||||
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
|
||||
WHERE NOT c.deleted
|
||||
AND NOT c.monoforum
|
||||
AND (c.broadcast OR c.megagroup)
|
||||
AND ($1::bigint = 0 OR (c.updated_at, c.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY c.updated_at DESC, c.id DESC
|
||||
LIMIT $3`, beforeUpdatedUS, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list channels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanChannelRows(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *readStore) ChannelDetail(ctx context.Context, channelID int64) (ChannelDetail, error) {
|
||||
var out ChannelDetail
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
|
||||
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
|
||||
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
|
||||
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at,
|
||||
row_to_json(c)::jsonb
|
||||
FROM channels c
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
|
||||
WHERE c.id = $1
|
||||
AND NOT c.deleted
|
||||
AND NOT c.monoforum
|
||||
AND (c.broadcast OR c.megagroup)`, channelID).Scan(channelScanDestWithRaw(&out.Channel, &raw)...)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("get channel: %w", err)
|
||||
}
|
||||
out.ChannelJSON = prettyJSON(raw)
|
||||
out.AuditLogs, err = s.channelAuditLogs(ctx, channelID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type channelScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanChannelRows(rows pgx.Rows) ([]ChannelRow, error) {
|
||||
out := make([]ChannelRow, 0)
|
||||
for rows.Next() {
|
||||
var item ChannelRow
|
||||
if err := scanChannelRow(rows, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanChannelRow(row channelScanner, item *ChannelRow) error {
|
||||
return row.Scan(channelScanDest(item)...)
|
||||
}
|
||||
|
||||
func channelScanDest(item *ChannelRow) []any {
|
||||
return []any{
|
||||
&item.ID, &item.AccessHash, &item.CreatorUserID, &item.Title, &item.About, &item.Username,
|
||||
&item.Broadcast, &item.Megagroup, &item.Forum, &item.Monoforum, &item.Verified, &item.Deleted,
|
||||
&item.ParticipantsCount, &item.AdminsCount, &item.KickedCount, &item.BannedCount,
|
||||
&item.TopMessageID, &item.PinnedMessageID, &item.PTS, &item.Date, &item.CreatedAt, &item.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func channelScanDestWithRaw(item *ChannelRow, raw *[]byte) []any {
|
||||
dest := channelScanDest(item)
|
||||
return append(dest, raw)
|
||||
}
|
||||
|
||||
func (s *readStore) ListAccounts(ctx context.Context, beforeActiveUS, beforeID int64, limit int) ([]AccountRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = accountListDefaultLimit
|
||||
}
|
||||
if limit > accountListMaxLimit {
|
||||
limit = accountListMaxLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH auth AS (
|
||||
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
|
||||
FROM authorizations
|
||||
GROUP BY user_id
|
||||
)
|
||||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
auth.last_active_at, auth.device_count,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
FROM users u
|
||||
JOIN auth ON auth.user_id = u.id
|
||||
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE NOT u.is_bot
|
||||
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY auth.last_active_at DESC, u.id DESC
|
||||
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AccountRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
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.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
|
||||
var out AccountDetail
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
u.about, u.last_seen_at, u.verified, u.support, u.is_bot,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
FROM users u
|
||||
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.id = $1`, userID).Scan(
|
||||
&out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName,
|
||||
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Support, &out.Bot,
|
||||
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.Account.Username,
|
||||
)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("get account: %w", err)
|
||||
}
|
||||
out.Restriction, out.HasRestriction, err = s.restriction(ctx, userID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Authorizations, err = s.authorizations(ctx, userID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.AuditLogs, err = s.auditLogs(ctx, userID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) {
|
||||
var r RestrictionRow
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT frozen, reason, actor, command_id, updated_at
|
||||
FROM account_send_restrictions
|
||||
WHERE user_id = $1`, userID).Scan(&r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return RestrictionRow{}, false, nil
|
||||
}
|
||||
return RestrictionRow{}, false, fmt.Errorf("get restriction: %w", err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func (s *readStore) authorizations(ctx context.Context, userID int64) ([]AuthorizationRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT auth_key_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE user_id = $1
|
||||
ORDER BY active_at DESC, created_at DESC
|
||||
LIMIT 100`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list authorizations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AuthorizationRow, 0)
|
||||
for rows.Next() {
|
||||
var a AuthorizationRow
|
||||
if err := rows.Scan(&a.AuthKeyID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, &a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *readStore) auditLogs(ctx context.Context, userID int64) ([]AuditLogRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
|
||||
FROM admin_audit_logs
|
||||
WHERE target_user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 30`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AuditLogRow, 0)
|
||||
for rows.Next() {
|
||||
var a AuditLogRow
|
||||
var result []byte
|
||||
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Result = prettyJSON(result)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *readStore) channelAuditLogs(ctx context.Context, channelID int64) ([]AuditLogRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
|
||||
FROM admin_audit_logs
|
||||
WHERE target_peer_type = 'channel' AND target_peer_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 30`, channelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list channel audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AuditLogRow, 0)
|
||||
for rows.Next() {
|
||||
var a AuditLogRow
|
||||
var result []byte
|
||||
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Result = prettyJSON(result)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type MessageRow struct {
|
||||
OwnerUserID int64
|
||||
BoxID int
|
||||
PrivateMessageID int64
|
||||
MessageSenderID int64
|
||||
PeerID int64
|
||||
FromUserID int64
|
||||
Date int64
|
||||
Outgoing bool
|
||||
Body string
|
||||
PTS int
|
||||
Deleted bool
|
||||
Media string
|
||||
}
|
||||
|
||||
type GroupMessageRow struct {
|
||||
ChannelID int64
|
||||
ID int
|
||||
SenderUserID int64
|
||||
FromPeerType string
|
||||
FromPeerID int64
|
||||
Date int64
|
||||
Post bool
|
||||
Body string
|
||||
PTS int
|
||||
Deleted bool
|
||||
Media string
|
||||
ViewsCount int
|
||||
EditDate int
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
func (s *readStore) ListMessages(ctx context.Context, ownerUserID, peerID int64, beforeDate int64, beforeID int, limit int) ([]MessageRow, error) {
|
||||
if limit <= 0 || limit > messagePageLimit {
|
||||
limit = messagePageLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
|
||||
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb)
|
||||
FROM message_boxes
|
||||
WHERE owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2
|
||||
AND ($3::bigint = 0 OR (message_date, box_id) < ($3::bigint, $4::int))
|
||||
ORDER BY message_date DESC, box_id DESC
|
||||
LIMIT $5`, ownerUserID, peerID, beforeDate, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]MessageRow, 0)
|
||||
for rows.Next() {
|
||||
var item MessageRow
|
||||
var media []byte
|
||||
if err := rows.Scan(&item.OwnerUserID, &item.BoxID, &item.PrivateMessageID, &item.MessageSenderID, &item.PeerID, &item.FromUserID, &item.Date, &item.Outgoing, &item.Body, &item.PTS, &item.Deleted, &media); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Media = prettyJSON(media)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *readStore) ListGroupMessages(ctx context.Context, channelID int64, beforeDate int64, beforeID int, limit int) ([]GroupMessageRow, error) {
|
||||
if limit <= 0 || limit > messagePageLimit {
|
||||
limit = messagePageLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
|
||||
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
|
||||
views_count, edit_date, pinned
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $1 AND NOT deleted
|
||||
AND ($2::bigint = 0 OR (message_date, id) < ($2::bigint, $3::int))
|
||||
ORDER BY message_date DESC, id DESC
|
||||
LIMIT $4`, channelID, beforeDate, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list group messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]GroupMessageRow, 0)
|
||||
for rows.Next() {
|
||||
var item GroupMessageRow
|
||||
var media []byte
|
||||
if err := rows.Scan(&item.ChannelID, &item.ID, &item.SenderUserID, &item.FromPeerType, &item.FromPeerID, &item.Date, &item.Post, &item.Body, &item.PTS, &item.Deleted, &media, &item.ViewsCount, &item.EditDate, &item.Pinned); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Media = prettyJSON(media)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type MessageDetail struct {
|
||||
Message MessageRow
|
||||
MessageJSON string
|
||||
DialogJSON string
|
||||
PrivateJSON string
|
||||
UpdateEvents []UpdateEventRow
|
||||
Outbox []OutboxRow
|
||||
}
|
||||
|
||||
type GroupMessageDetail struct {
|
||||
Message GroupMessageRow
|
||||
MessageJSON string
|
||||
ChannelJSON string
|
||||
UpdateEvents []ChannelUpdateEventRow
|
||||
}
|
||||
|
||||
type UpdateEventRow struct {
|
||||
PTS int
|
||||
PTSCount int
|
||||
Type string
|
||||
Date int64
|
||||
JSON string
|
||||
}
|
||||
|
||||
type ChannelUpdateEventRow struct {
|
||||
PTS int
|
||||
PTSCount int
|
||||
Type string
|
||||
MessageID int
|
||||
Date int64
|
||||
SenderUserID int64
|
||||
JSON string
|
||||
}
|
||||
|
||||
type OutboxRow struct {
|
||||
ID int64
|
||||
TargetUserID int64
|
||||
PTS int
|
||||
EventType string
|
||||
Status string
|
||||
Attempts int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *readStore) MessageDetail(ctx context.Context, ownerUserID int64, msgID int) (MessageDetail, error) {
|
||||
var out MessageDetail
|
||||
var media []byte
|
||||
var messageJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
|
||||
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb), row_to_json(mb)::jsonb
|
||||
FROM message_boxes mb
|
||||
WHERE owner_user_id = $1 AND box_id = $2`, ownerUserID, msgID).Scan(
|
||||
&out.Message.OwnerUserID, &out.Message.BoxID, &out.Message.PrivateMessageID, &out.Message.MessageSenderID, &out.Message.PeerID,
|
||||
&out.Message.FromUserID, &out.Message.Date, &out.Message.Outgoing, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted,
|
||||
&media, &messageJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("get message: %w", err)
|
||||
}
|
||||
out.Message.Media = prettyJSON(media)
|
||||
out.MessageJSON = prettyJSON(messageJSON)
|
||||
out.DialogJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(d)::jsonb FROM dialogs d WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, ownerUserID, out.Message.PeerID)
|
||||
out.PrivateJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(pm)::jsonb FROM private_messages pm WHERE id = $1`, out.Message.PrivateMessageID)
|
||||
events, err := s.updateEvents(ctx, ownerUserID, msgID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.UpdateEvents = events
|
||||
outbox, err := s.outbox(ctx, ownerUserID, out.Message.PTS)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Outbox = outbox
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *readStore) GroupMessageDetail(ctx context.Context, channelID int64, msgID int) (GroupMessageDetail, error) {
|
||||
var out GroupMessageDetail
|
||||
var media []byte
|
||||
var messageJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
|
||||
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
|
||||
views_count, edit_date, pinned, row_to_json(cm)::jsonb
|
||||
FROM channel_messages cm
|
||||
WHERE channel_id = $1 AND id = $2`, channelID, msgID).Scan(
|
||||
&out.Message.ChannelID, &out.Message.ID, &out.Message.SenderUserID, &out.Message.FromPeerType, &out.Message.FromPeerID,
|
||||
&out.Message.Date, &out.Message.Post, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted, &media,
|
||||
&out.Message.ViewsCount, &out.Message.EditDate, &out.Message.Pinned, &messageJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("get group message: %w", err)
|
||||
}
|
||||
out.Message.Media = prettyJSON(media)
|
||||
out.MessageJSON = prettyJSON(messageJSON)
|
||||
out.ChannelJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(c)::jsonb FROM channels c WHERE id = $1`, channelID)
|
||||
events, err := s.channelUpdateEvents(ctx, channelID, msgID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.UpdateEvents = events
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *readStore) rowJSON(ctx context.Context, sql string, args ...any) (string, error) {
|
||||
var raw []byte
|
||||
if err := s.pool.QueryRow(ctx, sql, args...).Scan(&raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(raw), nil
|
||||
}
|
||||
|
||||
func (s *readStore) updateEvents(ctx context.Context, ownerUserID int64, msgID int) ([]UpdateEventRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT pts, pts_count, event_type, date, row_to_json(e)::jsonb
|
||||
FROM user_update_events e
|
||||
WHERE user_id = $1 AND (
|
||||
message_box_id = $2 OR message_ids @> $3::jsonb
|
||||
)
|
||||
ORDER BY pts DESC
|
||||
LIMIT 20`, ownerUserID, msgID, fmt.Sprintf("[%d]", msgID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list update events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]UpdateEventRow, 0)
|
||||
for rows.Next() {
|
||||
var e UpdateEventRow
|
||||
var raw []byte
|
||||
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.Date, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.JSON = prettyJSON(raw)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *readStore) channelUpdateEvents(ctx context.Context, channelID int64, msgID int) ([]ChannelUpdateEventRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT pts, pts_count, event_type, message_id, date, sender_user_id, row_to_json(e)::jsonb
|
||||
FROM channel_update_events e
|
||||
WHERE channel_id = $1 AND (
|
||||
message_id = $2 OR message_ids @> $3::jsonb
|
||||
)
|
||||
ORDER BY pts DESC
|
||||
LIMIT 20`, channelID, msgID, fmt.Sprintf("[%d]", msgID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list channel update events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]ChannelUpdateEventRow, 0)
|
||||
for rows.Next() {
|
||||
var e ChannelUpdateEventRow
|
||||
var raw []byte
|
||||
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.MessageID, &e.Date, &e.SenderUserID, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.JSON = prettyJSON(raw)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *readStore) outbox(ctx context.Context, targetUserID int64, pts int) ([]OutboxRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, target_user_id, pts, event_type, status, attempts, created_at, updated_at
|
||||
FROM dispatch_outbox
|
||||
WHERE target_user_id = $1 AND pts = $2
|
||||
ORDER BY id DESC
|
||||
LIMIT 20`, targetUserID, pts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list outbox: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]OutboxRow, 0)
|
||||
for rows.Next() {
|
||||
var row OutboxRow
|
||||
if err := rows.Scan(&row.ID, &row.TargetUserID, &row.PTS, &row.EventType, &row.Status, &row.Attempts, &row.CreatedAt, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func prettyJSON(raw []byte) string {
|
||||
if len(raw) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
out, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
711
cmd/telesrv-admin/server.go
Normal file
711
cmd/telesrv-admin/server.go
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var webDist embed.FS
|
||||
|
||||
type server struct {
|
||||
cfg uiConfig
|
||||
read *readStore
|
||||
web fs.FS
|
||||
webServer http.Handler
|
||||
}
|
||||
|
||||
func newServer(cfg uiConfig, read *readStore) (*server, error) {
|
||||
web, err := fs.Sub(webDist, "web/dist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &server{
|
||||
cfg: cfg,
|
||||
read: read,
|
||||
web: web,
|
||||
webServer: http.FileServer(http.FS(web)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/login", s.handleAPILogin)
|
||||
mux.HandleFunc("POST /api/logout", s.handleAPILogout)
|
||||
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/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
|
||||
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
|
||||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("POST /api/actions/freeze-send", s.requireAuthAPI(http.HandlerFunc(s.handleFreezeSendAPI)))
|
||||
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
|
||||
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||
})
|
||||
mux.HandleFunc("/", s.handleApp)
|
||||
return mux
|
||||
}
|
||||
|
||||
type actorKey struct{}
|
||||
|
||||
func (s *server) requireAuthAPI(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now())
|
||||
if !ok {
|
||||
clearSessionCookie(w)
|
||||
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, claims.Actor)))
|
||||
})
|
||||
}
|
||||
|
||||
func actorFromContext(ctx context.Context) string {
|
||||
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
|
||||
return actor
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
|
||||
clean := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if clean != "." && clean != "" {
|
||||
if info, err := fs.Stat(s.web, clean); err == nil && !info.IsDir() {
|
||||
s.webServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/"
|
||||
s.webServer.ServeHTTP(w, r2)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !s.validSecret(req.Secret) {
|
||||
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
|
||||
return
|
||||
}
|
||||
value, err := signSession(s.cfg.SessionKey, sessionClaims{
|
||||
Actor: "admin",
|
||||
Exp: time.Now().Add(12 * time.Hour).Unix(),
|
||||
Nonce: newCommandID("sess"),
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: int((12 * time.Hour).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"actor": "admin"})
|
||||
}
|
||||
|
||||
func (s *server) validSecret(secret string) bool {
|
||||
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
|
||||
return true
|
||||
}
|
||||
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
|
||||
clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"actor": actorFromContext(r.Context())})
|
||||
}
|
||||
|
||||
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
|
||||
beforeActiveUS, _ := parseInt64(r.URL.Query().Get("before_active_us"))
|
||||
limit, _ := parseInt(r.URL.Query().Get("limit"))
|
||||
rows := []AccountRow{}
|
||||
hasMore := false
|
||||
var err error
|
||||
if strings.TrimSpace(q) != "" {
|
||||
rows, err = s.read.SearchAccounts(r.Context(), q)
|
||||
} else {
|
||||
rows, hasMore, err = s.read.ListAccounts(r.Context(), beforeActiveUS, beforeID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := int64(0)
|
||||
nextBeforeActiveUS := int64(0)
|
||||
if hasMore && len(rows) > 0 {
|
||||
last := rows[len(rows)-1]
|
||||
nextBeforeID = last.ID
|
||||
nextBeforeActiveUS = last.LastActiveAt.UnixMicro()
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = accountListDefaultLimit
|
||||
}
|
||||
if limit > accountListMaxLimit {
|
||||
limit = accountListMaxLimit
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"query": q,
|
||||
"limit": limit,
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
"next_before_active_us": nextBeforeActiveUS,
|
||||
"listing": strings.TrimSpace(q) == "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
userID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.AccountDetail(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
|
||||
beforeUpdatedUS, _ := parseInt64(r.URL.Query().Get("before_updated_us"))
|
||||
limit, _ := parseInt(r.URL.Query().Get("limit"))
|
||||
rows := []ChannelRow{}
|
||||
hasMore := false
|
||||
var err error
|
||||
if strings.TrimSpace(q) != "" {
|
||||
rows, err = s.read.SearchChannels(r.Context(), q)
|
||||
} else {
|
||||
rows, hasMore, err = s.read.ListChannels(r.Context(), beforeUpdatedUS, beforeID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := int64(0)
|
||||
nextBeforeUpdatedUS := int64(0)
|
||||
if hasMore && len(rows) > 0 {
|
||||
last := rows[len(rows)-1]
|
||||
nextBeforeID = last.ID
|
||||
nextBeforeUpdatedUS = last.UpdatedAt.UnixMicro()
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = channelListDefaultLimit
|
||||
}
|
||||
if limit > channelListMaxLimit {
|
||||
limit = channelListMaxLimit
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"query": q,
|
||||
"limit": limit,
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
"next_before_updated_us": nextBeforeUpdatedUS,
|
||||
"listing": strings.TrimSpace(q) == "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleChannelDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
channelID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || channelID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.ChannelDetail(r.Context(), channelID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *server) handleMessagesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
owner, _ := parseInt64(q.Get("owner_user_id"))
|
||||
peer, _ := parseInt64(q.Get("peer_id"))
|
||||
beforeDate, _ := parseInt64(q.Get("before_date"))
|
||||
beforeID, _ := parseInt(q.Get("before_id"))
|
||||
limit, _ := parseInt(q.Get("limit"))
|
||||
rows := []MessageRow{}
|
||||
var err error
|
||||
if owner > 0 && peer > 0 {
|
||||
rows, err = s.read.ListMessages(r.Context(), owner, peer, beforeDate, beforeID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"owner_user_id": owner,
|
||||
"peer_id": peer,
|
||||
"before_date": beforeDate,
|
||||
"before_id": beforeID,
|
||||
"limit": limit,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
owner, err1 := parseInt64(r.URL.Query().Get("owner_user_id"))
|
||||
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
|
||||
if err1 != nil || err2 != nil || owner <= 0 || msgID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid owner/msg_id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.MessageDetail(r.Context(), owner, msgID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *server) handleGroupMessagesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
channelID, _ := parseInt64(q.Get("channel_id"))
|
||||
beforeDate, _ := parseInt64(q.Get("before_date"))
|
||||
beforeID, _ := parseInt(q.Get("before_id"))
|
||||
limit, _ := parseInt(q.Get("limit"))
|
||||
rows := []GroupMessageRow{}
|
||||
var err error
|
||||
if channelID > 0 {
|
||||
rows, err = s.read.ListGroupMessages(r.Context(), channelID, beforeDate, beforeID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if limit <= 0 || limit > messagePageLimit {
|
||||
limit = messagePageLimit
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"channel_id": channelID,
|
||||
"before_date": beforeDate,
|
||||
"before_id": beforeID,
|
||||
"limit": limit,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleGroupMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
channelID, err1 := parseInt64(r.URL.Query().Get("channel_id"))
|
||||
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
|
||||
if err1 != nil || err2 != nil || channelID <= 0 || msgID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid channel_id/msg_id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.GroupMessageDetail(r.Context(), channelID, msgID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
type freezeSendAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Frozen bool `json:"frozen"`
|
||||
}
|
||||
|
||||
func (s *server) handleFreezeSendAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body freezeSendAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetSendFrozenRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "freeze-send"),
|
||||
UserID: body.UserID,
|
||||
Frozen: body.Frozen,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/freeze-send", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type grantPremiumAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Months int `json:"months"`
|
||||
}
|
||||
|
||||
func (s *server) handleGrantPremiumAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body grantPremiumAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.GrantPremiumRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-premium"),
|
||||
UserID: body.UserID,
|
||||
Months: body.Months,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-premium", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setVerifiedAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetVerifiedAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setVerifiedAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetVerifiedRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-verified"),
|
||||
UserID: body.UserID,
|
||||
Verified: body.Verified,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-verified", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setChannelVerifiedAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetChannelVerifiedAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setChannelVerifiedAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetChannelVerifiedRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-verified"),
|
||||
ChannelID: body.ChannelID,
|
||||
Verified: body.Verified,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-verified", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type revokeSessionsAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Hash int64 `json:"hash"`
|
||||
KeepHash int64 `json:"keep_hash"`
|
||||
RevokeAll bool `json:"revoke_all"`
|
||||
}
|
||||
|
||||
func (s *server) handleRevokeSessionsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body revokeSessionsAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RevokeSessionsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-sessions"),
|
||||
UserID: body.UserID,
|
||||
Hash: body.Hash,
|
||||
KeepHash: body.KeepHash,
|
||||
RevokeAll: body.RevokeAll,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/revoke-sessions", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteMessagesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
IDs []int `json:"ids"`
|
||||
Revoke bool `json:"revoke"`
|
||||
}
|
||||
|
||||
func (s *server) handleDeleteMessagesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body deleteMessagesAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.DeletePrivateMessagesRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-messages"),
|
||||
OwnerUserID: body.OwnerUserID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
|
||||
IDs: body.IDs,
|
||||
Revoke: body.Revoke,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteHistoryAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
MaxID int `json:"max_id"`
|
||||
MinDate int `json:"min_date"`
|
||||
MaxDate int `json:"max_date"`
|
||||
MaxBatches int `json:"max_batches"`
|
||||
JustClear bool `json:"just_clear"`
|
||||
Revoke bool `json:"revoke"`
|
||||
}
|
||||
|
||||
func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body deleteHistoryAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.DeletePrivateHistoryRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-history"),
|
||||
OwnerUserID: body.OwnerUserID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
|
||||
MaxID: body.MaxID,
|
||||
MinDate: body.MinDate,
|
||||
MaxDate: body.MaxDate,
|
||||
JustClear: body.JustClear,
|
||||
Revoke: body.Revoke,
|
||||
MaxBatches: body.MaxBatches,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete-history", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
if confirm && strings.HasPrefix(commandID, "dry-") {
|
||||
commandID = ""
|
||||
}
|
||||
dryRun := !confirm
|
||||
if commandID == "" {
|
||||
scope := "dry"
|
||||
if !dryRun {
|
||||
scope = "exec"
|
||||
}
|
||||
commandID = newCommandID(scope + "-" + prefix)
|
||||
}
|
||||
return admin.CommandMeta{
|
||||
CommandID: commandID,
|
||||
Actor: actorFromContext(r.Context()),
|
||||
Reason: reason,
|
||||
DryRun: dryRun,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any) (admin.CommandResult, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if result.Error == "" {
|
||||
result.Error = resp.Status
|
||||
}
|
||||
return result, errors.New(result.Error)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if err := decodeJSON(r, dst); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeCommandResultAPI(w http.ResponseWriter, result admin.CommandResult, err error) {
|
||||
if err != nil {
|
||||
if result.Status == "" {
|
||||
result.Status = "failed"
|
||||
}
|
||||
if result.Message == "" {
|
||||
result.Message = "command failed"
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusBadGateway, result)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func parseInt64(v string) (int64, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
|
||||
func parseInt(v string) (int, error) {
|
||||
n, err := parseInt64(v)
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func boolValue(v bool) string {
|
||||
if v {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func displayPhone(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || strings.HasPrefix(v, "+") {
|
||||
return v
|
||||
}
|
||||
for _, r := range v {
|
||||
if r < '0' || r > '9' {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "+" + v
|
||||
}
|
||||
|
||||
func channelKind(ch ChannelRow) string {
|
||||
if ch.Broadcast && !ch.Megagroup {
|
||||
return "频道"
|
||||
}
|
||||
if ch.Megagroup {
|
||||
if ch.Forum {
|
||||
return "超级群/论坛"
|
||||
}
|
||||
return "超级群"
|
||||
}
|
||||
return "频道/群"
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
68
cmd/telesrv-admin/session.go
Normal file
68
cmd/telesrv-admin/session.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sessionCookieName = "telesrv_admin_session"
|
||||
|
||||
type sessionClaims struct {
|
||||
Actor string `json:"actor"`
|
||||
Exp int64 `json:"exp"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
func signSession(key []byte, claims sessionClaims) (string, error) {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encPayload := base64.RawURLEncoding.EncodeToString(payload)
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(encPayload))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return encPayload + "." + sig, nil
|
||||
}
|
||||
|
||||
func verifySession(key []byte, value string, now time.Time) (sessionClaims, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 2 {
|
||||
return sessionClaims{}, false
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(parts[0]))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(want)) != 1 {
|
||||
return sessionClaims{}, false
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return sessionClaims{}, false
|
||||
}
|
||||
var claims sessionClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return sessionClaims{}, false
|
||||
}
|
||||
if claims.Actor == "" || claims.Exp <= now.Unix() {
|
||||
return sessionClaims{}, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
44
cmd/telesrv-admin/session_test.go
Normal file
44
cmd/telesrv-admin/session_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignedSessionRoundTripAndTamper(t *testing.T) {
|
||||
key := []byte("01234567890123456789012345678901")
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
value, err := signSession(key, sessionClaims{Actor: "admin", Exp: now.Add(time.Hour).Unix(), Nonce: "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("signSession: %v", err)
|
||||
}
|
||||
claims, ok := verifySession(key, value, now)
|
||||
if !ok || claims.Actor != "admin" {
|
||||
t.Fatalf("verify ok=%v claims=%+v", ok, claims)
|
||||
}
|
||||
if _, ok := verifySession(key, value+"x", now); ok {
|
||||
t.Fatal("tampered session verified")
|
||||
}
|
||||
if _, ok := verifySession(key, value, now.Add(2*time.Hour)); ok {
|
||||
t.Fatal("expired session verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAFallbackSmoke(t *testing.T) {
|
||||
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newServer: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `<div id="root"></div>`) {
|
||||
t.Fatalf("spa body missing root: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
1
cmd/telesrv-admin/web/dist/assets/index-IDEWWIS9.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-IDEWWIS9.css
vendored
Normal file
File diff suppressed because one or more lines are too long
8
cmd/telesrv-admin/web/dist/assets/index-m3onrdER.js
vendored
Normal file
8
cmd/telesrv-admin/web/dist/assets/index-m3onrdER.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
cmd/telesrv-admin/web/dist/index.html
vendored
Normal file
13
cmd/telesrv-admin/web/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>telesrv admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-m3onrdER.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-IDEWWIS9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
cmd/telesrv-admin/web/index.html
Normal file
12
cmd/telesrv-admin/web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>telesrv admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1017
cmd/telesrv-admin/web/package-lock.json
generated
Normal file
1017
cmd/telesrv-admin/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
cmd/telesrv-admin/web/package.json
Normal file
23
cmd/telesrv-admin/web/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "telesrv-admin-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^8.1.0"
|
||||
}
|
||||
}
|
||||
48
cmd/telesrv-admin/web/src/App.tsx
Normal file
48
cmd/telesrv-admin/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api, APIError } from "./api";
|
||||
import { BootScreen, Shell } from "./components/Layout";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { Routes } from "./pages/Routes";
|
||||
import { currentRoute, type RouteState } from "./routing";
|
||||
|
||||
export function App() {
|
||||
const [actor, setActor] = useState<string | null | undefined>(undefined);
|
||||
const [route, setRoute] = useState<RouteState>(() => currentRoute());
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setRoute(currentRoute());
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.session()
|
||||
.then((session) => setActor(session.actor))
|
||||
.catch((error) => {
|
||||
if (error instanceof APIError && error.status === 401) {
|
||||
setActor(null);
|
||||
return;
|
||||
}
|
||||
setActor(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const navigate = (href: string) => {
|
||||
window.history.pushState(null, "", href);
|
||||
setRoute(currentRoute());
|
||||
};
|
||||
|
||||
if (actor === undefined) {
|
||||
return <BootScreen />;
|
||||
}
|
||||
|
||||
if (actor === null) {
|
||||
return <LoginPage onLogin={setActor} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
72
cmd/telesrv-admin/web/src/api.ts
Normal file
72
cmd/telesrv-admin/web/src/api.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type {
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
ChannelDetail,
|
||||
ChannelListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse
|
||||
} from "./types";
|
||||
|
||||
export class APIError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {})
|
||||
},
|
||||
...init
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!response.ok) {
|
||||
const message = data?.error || data?.Error || data?.message || response.statusText;
|
||||
throw new APIError(response.status, message);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session: () => request<{ actor: string }>("/api/session"),
|
||||
login: (secret: string) => request<{ actor: string }>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret })
|
||||
}),
|
||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
||||
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
||||
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
||||
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
||||
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
||||
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
|
||||
message: (ownerUserID: number, msgID: number) => {
|
||||
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });
|
||||
return request<MessageDetail>(`/api/messages/detail?${params.toString()}`);
|
||||
},
|
||||
groupMessages: (params: URLSearchParams) => request<GroupMessageListResponse>(`/api/messages/groups?${params.toString()}`),
|
||||
groupMessage: (channelID: number, msgID: number) => {
|
||||
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
|
||||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||
},
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
};
|
||||
146
cmd/telesrv-admin/web/src/components/ActionButton.tsx
Normal file
146
cmd/telesrv-admin/web/src/components/ActionButton.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { CheckCircle2, CircleAlert, FileJson, Loader2, Play, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import type { CommandResult } from "../types";
|
||||
import { Alert, JsonBlock } from "./ui";
|
||||
|
||||
type ActionTone = "neutral" | "warn" | "danger";
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
path,
|
||||
payload,
|
||||
icon,
|
||||
compact = false,
|
||||
tone = "danger",
|
||||
onDone
|
||||
}: {
|
||||
label: string;
|
||||
path: string;
|
||||
payload: () => Record<string, unknown>;
|
||||
icon?: ReactNode;
|
||||
compact?: boolean;
|
||||
tone?: ActionTone;
|
||||
onDone?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setReason("");
|
||||
setResult(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function run(confirm: boolean) {
|
||||
if (!reason.trim()) {
|
||||
setError("请填写操作原因");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = { ...payload(), reason, confirm };
|
||||
const commandResult = await api.action(path, body);
|
||||
setResult(commandResult);
|
||||
if (confirm) {
|
||||
onDone?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canConfirm = result?.dry_run && !result.error;
|
||||
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
|
||||
const previewPayload = useMemo(() => {
|
||||
try {
|
||||
return payload();
|
||||
} catch (err) {
|
||||
return { payload_error: errorMessage(err) };
|
||||
}
|
||||
}, [open, payload]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={triggerClass}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">操作流程</div>
|
||||
<h2>{label}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label="关闭"><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${reason.trim() ? "done" : "active"}`}>
|
||||
<span>1</span><strong>填写原因</strong>
|
||||
</div>
|
||||
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
|
||||
<span>2</span><strong>预演检查</strong>
|
||||
</div>
|
||||
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
|
||||
<span>3</span><strong>确认执行</strong>
|
||||
</div>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span>操作原因</span>
|
||||
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="说明本次操作原因" />
|
||||
</label>
|
||||
<div className="command-preview">
|
||||
<div className="preview-head"><FileJson size={14} /> 请求预览</div>
|
||||
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{result && (
|
||||
<div className="result-box">
|
||||
<div className="result-title">
|
||||
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
|
||||
<strong>{result.message || result.error || "操作结果"}</strong>
|
||||
</div>
|
||||
<div className="result-line"><span>命令 ID</span><strong>{result.command_id}</strong></div>
|
||||
<div className="result-line"><span>状态</span><strong>{result.status}</strong></div>
|
||||
<div className="result-line"><span>预演</span><strong>{result.dry_run ? "是" : "否"}</strong></div>
|
||||
<div className="result-message">{result.message || result.error}</div>
|
||||
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={() => setOpen(false)}>关闭</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
|
||||
{result ? "重新预演" : "先预演"}
|
||||
</button>
|
||||
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
|
||||
<CheckCircle2 size={15} />
|
||||
确认执行
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
cmd/telesrv-admin/web/src/components/AppLink.tsx
Normal file
27
cmd/telesrv-admin/web/src/components/AppLink.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Navigate } from "../routing";
|
||||
|
||||
export function AppLink({
|
||||
href,
|
||||
navigate,
|
||||
className,
|
||||
children
|
||||
}: {
|
||||
href: string;
|
||||
navigate: Navigate;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigate(href);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
82
cmd/telesrv-admin/web/src/components/AuthorizationTable.tsx
Normal file
82
cmd/telesrv-admin/web/src/components/AuthorizationTable.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Cable, LogOut, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { AuthorizationRow } from "../types";
|
||||
import { ActionButton } from "./ActionButton";
|
||||
import { EmptyRow } from "./ui";
|
||||
|
||||
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
|
||||
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setRemovedHashes(new Set());
|
||||
}, [userID]);
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => rows.filter((row) => !removedHashes.has(row.Hash)),
|
||||
[rows, removedHashes]
|
||||
);
|
||||
|
||||
function afterRevoke(mutator: (previous: Set<number>) => Set<number>) {
|
||||
setRemovedHashes((previous) => mutator(previous));
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="authorization-block">
|
||||
<div className="table-wrap">
|
||||
<table className="data-table authorization-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备</th>
|
||||
<th>平台</th>
|
||||
<th>IP</th>
|
||||
<th>最近活跃</th>
|
||||
<th className="device-actions-head">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((row) => (
|
||||
<tr key={row.Hash}>
|
||||
<td className="device-text">{row.DeviceModel} {row.SystemVersion}</td>
|
||||
<td className="device-text">{row.Platform} {row.AppVersion}</td>
|
||||
<td>{row.IP}</td>
|
||||
<td>{formatDate(row.ActiveAt)}</td>
|
||||
<td className="device-actions-cell">
|
||||
<div className="device-actions">
|
||||
<ActionButton
|
||||
label="撤销当前"
|
||||
icon={<LogOut size={13} />}
|
||||
compact
|
||||
path="/api/actions/revoke-sessions"
|
||||
payload={() => ({ user_id: userID, hash: row.Hash })}
|
||||
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
|
||||
/>
|
||||
<ActionButton
|
||||
label="保留当前"
|
||||
icon={<ShieldCheck size={13} />}
|
||||
compact
|
||||
path="/api/actions/revoke-sessions"
|
||||
payload={() => ({ user_id: userID, keep_hash: row.Hash })}
|
||||
onDone={() => afterRevoke(() => new Set(rows.filter((item) => item.Hash !== row.Hash).map((item) => item.Hash)))}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visibleRows.length === 0 && <EmptyRow colSpan={5} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label="撤销全部设备"
|
||||
icon={<Cable size={15} />}
|
||||
path="/api/actions/revoke-sessions"
|
||||
payload={() => ({ user_id: userID, revoke_all: true })}
|
||||
onDone={() => afterRevoke(() => new Set(rows.map((item) => item.Hash)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
cmd/telesrv-admin/web/src/components/EntityPicker.tsx
Normal file
192
cmd/telesrv-admin/web/src/components/EntityPicker.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { Check, Loader2, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
|
||||
import type { AccountRow, ChannelRow } from "../types";
|
||||
import { Badge } from "./ui";
|
||||
|
||||
export function UserPicker({
|
||||
label,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: AccountRow | null;
|
||||
onChange: (row: AccountRow | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<AccountRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function search() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "20" });
|
||||
if (query.trim()) {
|
||||
params.set("q", query.trim());
|
||||
}
|
||||
try {
|
||||
const result = await api.accounts(params);
|
||||
setRows(result.rows);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void search();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="entity-picker">
|
||||
<div className="picker-head">
|
||||
<span>{label}</span>
|
||||
{value ? (
|
||||
<button className="link-button" type="button" onClick={() => onChange(null)}>
|
||||
<X size={13} /> 清除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{value ? (
|
||||
<div className="selected-entity">
|
||||
<Check size={15} />
|
||||
<div>
|
||||
<strong>{displayName(value)}</strong>
|
||||
<span className="mono">{value.ID}</span>
|
||||
</div>
|
||||
<span>{displayUsername(value.Username) || displayPhone(value.Phone) || "-"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="picker-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void search();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索 user_id / phone / username"
|
||||
/>
|
||||
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="picker-error">{error}</div>}
|
||||
<div className="picker-results">
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.ID}
|
||||
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(row)}
|
||||
>
|
||||
<span className="mono">{row.ID}</span>
|
||||
<strong>{displayName(row)}</strong>
|
||||
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
|
||||
{row.Verified ? <Badge tone="good">认证</Badge> : <Badge>普通</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">无结果</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelPicker({
|
||||
label,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: ChannelRow | null;
|
||||
onChange: (row: ChannelRow | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<ChannelRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function search() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "20" });
|
||||
if (query.trim()) {
|
||||
params.set("q", query.trim());
|
||||
}
|
||||
try {
|
||||
const result = await api.channels(params);
|
||||
setRows(result.rows);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void search();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="entity-picker">
|
||||
<div className="picker-head">
|
||||
<span>{label}</span>
|
||||
{value ? (
|
||||
<button className="link-button" type="button" onClick={() => onChange(null)}>
|
||||
<X size={13} /> 清除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{value ? (
|
||||
<div className="selected-entity">
|
||||
<Check size={15} />
|
||||
<div>
|
||||
<strong>{value.Title || "-"}</strong>
|
||||
<span className="mono">{value.ID}</span>
|
||||
</div>
|
||||
<span>{displayUsername(value.Username) || channelKind(value)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="picker-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void search();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索 channel_id / username / title"
|
||||
/>
|
||||
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="picker-error">{error}</div>}
|
||||
<div className="picker-results">
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.ID}
|
||||
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(row)}
|
||||
>
|
||||
<span className="mono">{row.ID}</span>
|
||||
<strong>{row.Title || "-"}</strong>
|
||||
<span>{displayUsername(row.Username) || channelKind(row)}</span>
|
||||
{row.Verified ? <Badge tone="good">认证</Badge> : <Badge>{channelKind(row)}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">无结果</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
cmd/telesrv-admin/web/src/components/Layout.tsx
Normal file
155
cmd/telesrv-admin/web/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import {
|
||||
ChevronDown,
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Users
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
|
||||
import { AppLink } from "./AppLink";
|
||||
|
||||
export function BootScreen() {
|
||||
return (
|
||||
<div className="boot-screen">
|
||||
<div className="brand compact brand-elevated">
|
||||
<span className="brand-mark">T</span>
|
||||
<span>
|
||||
<strong>telesrv</strong>
|
||||
<small>管理控制台</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="loader-bar" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Shell({
|
||||
actor,
|
||||
route,
|
||||
navigate,
|
||||
onLogout,
|
||||
children
|
||||
}: {
|
||||
actor: string;
|
||||
route: RouteState;
|
||||
navigate: Navigate;
|
||||
onLogout: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const messagesActive = route.path.startsWith("/messages");
|
||||
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesActive) {
|
||||
setMessagesOpen(true);
|
||||
}
|
||||
}, [messagesActive]);
|
||||
|
||||
async function logout() {
|
||||
await api.logout().catch(() => undefined);
|
||||
onLogout();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<AppLink className="brand" href="/" navigate={navigate}>
|
||||
<span className="brand-mark">T</span>
|
||||
<span>
|
||||
<strong>telesrv</strong>
|
||||
<small>管理控制台</small>
|
||||
</span>
|
||||
</AppLink>
|
||||
<div className="sidebar-label">导航</div>
|
||||
<nav className="nav-list" aria-label="主导航">
|
||||
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>总览</NavLink>
|
||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>账号</NavLink>
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>超级群/频道</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="nav-section-toggle"
|
||||
type="button"
|
||||
aria-expanded={messagesOpen}
|
||||
onClick={() => setMessagesOpen((open) => !open)}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
<span>消息</span>
|
||||
<ChevronDown className="nav-section-chevron" size={15} />
|
||||
</button>
|
||||
{messagesOpen && (
|
||||
<div className="nav-children">
|
||||
<NavLink
|
||||
href="/messages/private"
|
||||
route={route}
|
||||
navigate={navigate}
|
||||
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
|
||||
>
|
||||
私聊
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href="/messages/groups"
|
||||
route={route}
|
||||
navigate={navigate}
|
||||
activeWhen={(path) => path.startsWith("/messages/groups")}
|
||||
>
|
||||
群聊
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<div className="sidebar-label">运行状态</div>
|
||||
<div className="runtime-row"><Server size={14} /><span>管理后台</span><strong>就绪</strong></div>
|
||||
<div className="runtime-row"><Database size={14} /><span>PG 读取</span><strong>只读</strong></div>
|
||||
<div className="runtime-row"><Shield size={14} /><span>写操作</span><strong>预演</strong></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<div className="eyebrow">{routeSubtitle(route.path)}</div>
|
||||
<h1>{routeTitle(route.path)}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<span className="actor-pill">操作者:{actor}</span>
|
||||
<button className="btn ghost icon-text" type="button" onClick={logout} title="退出">
|
||||
<LogOut size={16} /> 退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
route,
|
||||
navigate,
|
||||
icon,
|
||||
children,
|
||||
activeWhen
|
||||
}: {
|
||||
href: string;
|
||||
route: RouteState;
|
||||
navigate: Navigate;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
activeWhen?: (path: string) => boolean;
|
||||
}) {
|
||||
const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href);
|
||||
return (
|
||||
<AppLink className={`nav-item ${active ? "active" : ""}`} href={href} navigate={navigate}>
|
||||
{icon ?? <span aria-hidden="true" className="nav-dot" />}
|
||||
<span>{children}</span>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
128
cmd/telesrv-admin/web/src/components/ui.tsx
Normal file
128
cmd/telesrv-admin/web/src/components/ui.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { CircleAlert } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { AuditLogRow } from "../types";
|
||||
|
||||
type Tone = "neutral" | "good" | "danger" | "warn";
|
||||
|
||||
export function PageFrame({
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="page-frame">
|
||||
<div className="page-title-row">
|
||||
<div>
|
||||
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryPanel({ children }: { children: ReactNode }) {
|
||||
return <div className="query-panel">{children}</div>;
|
||||
}
|
||||
|
||||
export function SplitLayout({ main, side }: { main: ReactNode; side: ReactNode }) {
|
||||
return (
|
||||
<div className="split-layout">
|
||||
<div className="split-main">{main}</div>
|
||||
<aside className="split-side">{side}</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHead({ title, text, action }: { title: string; text?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{text && <p>{text}</p>}
|
||||
</div>
|
||||
{action && <div className="section-action">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Alert({ children }: { children: ReactNode }) {
|
||||
return <div className="alert"><CircleAlert size={16} /> <span>{children}</span></div>;
|
||||
}
|
||||
|
||||
export function Badge({ children, tone = "neutral" }: { children: ReactNode; tone?: Tone }) {
|
||||
return <span className={`badge ${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
export function StatusItem({ label, value, tone }: { label: string; value: string; tone: "neutral" | "good" | "warn" }) {
|
||||
return (
|
||||
<div className={`status-item ${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) {
|
||||
return (
|
||||
<div className={`metric ${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong className={mono ? "mono" : ""}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Summary({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="summary-item">
|
||||
<span>{label}</span>
|
||||
<strong className={mono ? "mono" : ""}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>ID</th><th>命令 ID</th><th>动作</th><th>操作者</th><th>状态</th><th>预演</th><th>原因</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td>{row.ID}</td>
|
||||
<td className="mono">{row.CommandID}</td>
|
||||
<td>{row.Action}</td>
|
||||
<td>{row.Actor}</td>
|
||||
<td>{row.Status}</td>
|
||||
<td>{row.DryRun ? "是" : "否"}</td>
|
||||
<td className="truncate">{row.Reason}</td>
|
||||
<td>{formatDate(row.CreatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyRow({ colSpan }: { colSpan: number }) {
|
||||
return <tr><td colSpan={colSpan} className="empty-cell">无结果</td></tr>;
|
||||
}
|
||||
|
||||
export function LoadingSurface({ label }: { label: string }) {
|
||||
return <section className="surface"><div className="loading-line">{label}</div></section>;
|
||||
}
|
||||
|
||||
export function JsonBlock({ value }: { value: string }) {
|
||||
return <pre className="json-block">{value || "{}"}</pre>;
|
||||
}
|
||||
56
cmd/telesrv-admin/web/src/lib/format.ts
Normal file
56
cmd/telesrv-admin/web/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { AccountRow, ChannelRow } from "../types";
|
||||
|
||||
export function displayPhone(value: string): string {
|
||||
const phone = value.trim();
|
||||
if (!phone || phone.startsWith("+")) return phone;
|
||||
return /^\d+$/.test(phone) ? `+${phone}` : phone;
|
||||
}
|
||||
|
||||
export function displayUsername(value: string): string {
|
||||
const username = value.trim();
|
||||
if (!username) return "";
|
||||
return username.startsWith("@") ? username : `@${username}`;
|
||||
}
|
||||
|
||||
export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): string {
|
||||
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
|
||||
}
|
||||
|
||||
export function channelKind(ch: ChannelRow): string {
|
||||
if (ch.Broadcast && !ch.Megagroup) return "频道";
|
||||
if (ch.Megagroup && ch.Forum) return "超级群/论坛";
|
||||
if (ch.Megagroup) return "超级群";
|
||||
return "频道/群";
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
if (!value || value.startsWith("0001-")) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatUnix(value: number): string {
|
||||
if (!value || value <= 0) return "";
|
||||
const date = new Date(value * 1000);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function toInt(value: string): number {
|
||||
if (!value.trim()) return 0;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function parseIDs(value: string): number[] {
|
||||
const ids = value
|
||||
.split(/[\s,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => Number.parseInt(item, 10));
|
||||
if (ids.length === 0 || ids.some((id) => !Number.isFinite(id) || id <= 0)) {
|
||||
throw new Error("msg ids invalid");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
25
cmd/telesrv-admin/web/src/lib/metrics.ts
Normal file
25
cmd/telesrv-admin/web/src/lib/metrics.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { AccountRow, ChannelRow } from "../types";
|
||||
|
||||
export function accountMetrics(rows: AccountRow[]) {
|
||||
return rows.reduce(
|
||||
(acc, row) => {
|
||||
acc.devices += row.DeviceCount;
|
||||
if (row.PremiumUntil > 0) acc.premium += 1;
|
||||
if (row.Frozen) acc.frozen += 1;
|
||||
return acc;
|
||||
},
|
||||
{ devices: 0, premium: 0, frozen: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
export function channelMetrics(rows: ChannelRow[]) {
|
||||
return rows.reduce(
|
||||
(acc, row) => {
|
||||
if (row.Megagroup) acc.megagroups += 1;
|
||||
if (row.Broadcast) acc.broadcasts += 1;
|
||||
if (row.Verified) acc.verified += 1;
|
||||
return acc;
|
||||
},
|
||||
{ megagroups: 0, broadcasts: 0, verified: 0 }
|
||||
);
|
||||
}
|
||||
10
cmd/telesrv-admin/web/src/main.tsx
Normal file
10
cmd/telesrv-admin/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
134
cmd/telesrv-admin/web/src/pages/AccountDetailPage.tsx
Normal file
134
cmd/telesrv-admin/web/src/pages/AccountDetailPage.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { AuthorizationTable } from "../components/AuthorizationTable";
|
||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountDetail } from "../types";
|
||||
|
||||
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<AccountDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [months, setMonths] = useState("1");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.account(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? "加载账号详情" : "等待数据"} />;
|
||||
}
|
||||
|
||||
const account = detail.Account;
|
||||
return (
|
||||
<PageFrame
|
||||
title={`账号 #${account.ID}`}
|
||||
eyebrow="账号档案"
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> 返回列表</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayName(account)}</div>
|
||||
<div className="entity-subtitle">{displayUsername(account.Username) || "无用户名"} · {displayPhone(account.Phone) || "无手机号"}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{account.PremiumUntil > 0 ? <Badge tone="good">会员</Badge> : <Badge>非会员</Badge>}
|
||||
{detail.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}
|
||||
{account.Frozen ? <Badge tone="danger">发消息冻结</Badge> : <Badge>发送正常</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label="用户 ID" value={String(account.ID)} mono />
|
||||
<Summary label="最后在线" value={formatUnix(detail.LastSeenAt) || "-"} />
|
||||
<Summary label="会员到期" value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "无"} />
|
||||
<Summary label="更新时间" value={formatDate(account.UpdatedAt) || "-"} />
|
||||
<Summary label="授权设备" value={String(detail.Authorizations.length)} />
|
||||
<Summary label="账号标记" value={`support=${detail.Support} bot=${detail.Bot}`} />
|
||||
<Summary label="限制状态" value={detail.HasRestriction ? detail.Restriction.Reason || "已限制" : "无"} />
|
||||
<Summary label="创建时间" value={formatDate(account.CreatedAt) || "-"} />
|
||||
</div>
|
||||
{detail.About && <p className="about-text">{detail.About}</p>}
|
||||
<section className="section-block">
|
||||
<SectionHead title="授权设备" text={`共 ${detail.Authorizations.length} 个授权`} />
|
||||
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="最近后台操作" text="最近 30 条审计" />
|
||||
<AuditTable rows={detail.AuditLogs} />
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">账号操作</div>
|
||||
<ActionButton
|
||||
label={account.Frozen ? "解冻发消息" : "冻结发消息"}
|
||||
icon={<CircleAlert size={15} />}
|
||||
path="/api/actions/freeze-send"
|
||||
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
|
||||
onDone={load}
|
||||
/>
|
||||
<label className="duration-field">
|
||||
<span>会员时长(月)</span>
|
||||
<input
|
||||
aria-label="设置会员时长,单位月"
|
||||
value={months}
|
||||
onChange={(event) => setMonths(event.target.value)}
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
/>
|
||||
</label>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label="设置会员"
|
||||
icon={<Sparkles size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/grant-premium"
|
||||
payload={() => ({ user_id: account.ID, months: toInt(months) })}
|
||||
onDone={load}
|
||||
/>
|
||||
<ActionButton
|
||||
label="取消会员"
|
||||
icon={<Sparkles size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/grant-premium"
|
||||
payload={() => ({ user_id: account.ID, months: 0 })}
|
||||
onDone={load}
|
||||
/>
|
||||
<ActionButton
|
||||
label={detail.Verified ? "取消认证" : "设置认证"}
|
||||
icon={<BadgeCheck size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/set-verified"
|
||||
payload={() => ({ user_id: account.ID, verified: !detail.Verified })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
124
cmd/telesrv-admin/web/src/pages/AccountsPage.tsx
Normal file
124
cmd/telesrv-admin/web/src/pages/AccountsPage.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
import { accountMetrics } from "../lib/metrics";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountListResponse } from "../types";
|
||||
|
||||
export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [data, setData] = useState<AccountListResponse | null>(null);
|
||||
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (q.trim()) {
|
||||
params.set("q", q.trim());
|
||||
} else if (next) {
|
||||
params.set("before_id", String(cursor.beforeID));
|
||||
params.set("before_active_us", String(cursor.beforeActiveUS));
|
||||
}
|
||||
try {
|
||||
const result = await api.accounts(params);
|
||||
setData(result);
|
||||
setCursor({
|
||||
beforeID: result.next_before_id,
|
||||
beforeActiveUS: result.next_before_active_us
|
||||
});
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const metrics = accountMetrics(data?.rows ?? []);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title="账号"
|
||||
eyebrow={data?.listing === false ? "查询结果" : "最近活跃账号"}
|
||||
actions={
|
||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} /> 刷新
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label="当前页账号" value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label="在线设备记录" value={String(metrics.devices)} />
|
||||
<Metric label="会员" value={String(metrics.premium)} tone="good" />
|
||||
<Metric label="冻结" value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="用户 ID / 手机号 / 用户名" />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>条数</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} 查询
|
||||
</button>
|
||||
{data?.listing && data.has_more && (
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
<ChevronRight size={15} /> 下一页
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户 ID</th>
|
||||
<th>手机号</th>
|
||||
<th>用户名</th>
|
||||
<th>姓名</th>
|
||||
<th>设备</th>
|
||||
<th>最近活跃</th>
|
||||
<th>会员</th>
|
||||
<th>认证</th>
|
||||
<th>冻结</th>
|
||||
<th>更新时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{displayPhone(row.Phone)}</td>
|
||||
<td>{displayUsername(row.Username)}</td>
|
||||
<td>{displayName(row)}</td>
|
||||
<td>{row.DeviceCount}</td>
|
||||
<td>{formatDate(row.LastActiveAt)}</td>
|
||||
<td>{row.PremiumUntil > 0 ? <Badge tone="good">会员 {formatUnix(row.PremiumUntil)}</Badge> : <Badge>无</Badge>}</td>
|
||||
<td>{row.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}</td>
|
||||
<td>{row.Frozen ? <Badge tone="danger">冻结</Badge> : <Badge>正常</Badge>}</td>
|
||||
<td>{formatDate(row.UpdatedAt)}</td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>详情 <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
92
cmd/telesrv-admin/web/src/pages/ChannelDetailPage.tsx
Normal file
92
cmd/telesrv-admin/web/src/pages/ChannelDetailPage.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { ArrowLeft, BadgeCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelDetail } from "../types";
|
||||
|
||||
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.channel(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label="加载频道详情" />;
|
||||
}
|
||||
|
||||
const ch = detail.Channel;
|
||||
return (
|
||||
<PageFrame
|
||||
title={`${channelKind(ch)} #${ch.ID}`}
|
||||
eyebrow="频道档案"
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> 返回列表</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{ch.Title || "-"}</div>
|
||||
<div className="entity-subtitle">{displayUsername(ch.Username) || "无用户名"} · 创建者 {ch.CreatorUserID}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<Badge>{channelKind(ch)}</Badge>
|
||||
{ch.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}
|
||||
{ch.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>有效</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label="频道 ID" value={String(ch.ID)} mono />
|
||||
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
|
||||
<Summary label="成员" value={`${ch.ParticipantsCount} / 管理员 ${ch.AdminsCount}`} />
|
||||
<Summary label="治理状态" value={`封禁 ${ch.BannedCount} / 踢出 ${ch.KickedCount}`} />
|
||||
<Summary label="频道标记" value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
|
||||
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
|
||||
<Summary label="创建时间" value={formatUnix(ch.Date) || "-"} />
|
||||
<Summary label="更新时间" value={formatDate(ch.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
{ch.About && <p className="about-text">{ch.About}</p>}
|
||||
<section className="section-block">
|
||||
<SectionHead title="最近后台操作" text="最近 30 条审计" />
|
||||
<AuditTable rows={detail.AuditLogs} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="频道原始行" text="数据库只读快照" />
|
||||
<JsonBlock value={detail.ChannelJSON} />
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">频道操作</div>
|
||||
<ActionButton
|
||||
label={ch.Verified ? "取消认证" : "设置认证"}
|
||||
icon={<BadgeCheck size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/set-channel-verified"
|
||||
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
|
||||
onDone={load}
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
122
cmd/telesrv-admin/web/src/pages/ChannelsPage.tsx
Normal file
122
cmd/telesrv-admin/web/src/pages/ChannelsPage.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { channelKind, displayUsername, formatDate } from "../lib/format";
|
||||
import { channelMetrics } from "../lib/metrics";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelListResponse } from "../types";
|
||||
|
||||
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [data, setData] = useState<ChannelListResponse | null>(null);
|
||||
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (q.trim()) {
|
||||
params.set("q", q.trim());
|
||||
} else if (next) {
|
||||
params.set("before_id", String(cursor.beforeID));
|
||||
params.set("before_updated_us", String(cursor.beforeUpdatedUS));
|
||||
}
|
||||
try {
|
||||
const result = await api.channels(params);
|
||||
setData(result);
|
||||
setCursor({
|
||||
beforeID: result.next_before_id,
|
||||
beforeUpdatedUS: result.next_before_updated_us
|
||||
});
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const metrics = channelMetrics(data?.rows ?? []);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title="超级群与频道"
|
||||
eyebrow={data?.listing === false ? "查询结果" : "最近更新"}
|
||||
actions={
|
||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} /> 刷新
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label="当前页实体" value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label="超级群" value={String(metrics.megagroups)} />
|
||||
<Metric label="频道" value={String(metrics.broadcasts)} />
|
||||
<Metric label="已认证" value={String(metrics.verified)} tone="good" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="频道 ID / 用户名 / 标题" />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>条数</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} 查询
|
||||
</button>
|
||||
{data?.listing && data.has_more && (
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
<ChevronRight size={15} /> 下一页
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>频道 ID</th>
|
||||
<th>类型</th>
|
||||
<th>用户名</th>
|
||||
<th>标题</th>
|
||||
<th>成员</th>
|
||||
<th>管理员</th>
|
||||
<th>PTS</th>
|
||||
<th>认证</th>
|
||||
<th>更新时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{channelKind(row)}</td>
|
||||
<td>{displayUsername(row.Username)}</td>
|
||||
<td>{row.Title}</td>
|
||||
<td>{row.ParticipantsCount}</td>
|
||||
<td>{row.AdminsCount}</td>
|
||||
<td>{row.PTS}</td>
|
||||
<td>{row.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}</td>
|
||||
<td>{formatDate(row.UpdatedAt)}</td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>详情 <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
59
cmd/telesrv-admin/web/src/pages/Dashboard.tsx
Normal file
59
cmd/telesrv-admin/web/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareText, ShieldCheck, Users } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AppLink } from "../components/AppLink";
|
||||
import { StatusItem } from "../components/ui";
|
||||
import type { Navigate } from "../routing";
|
||||
|
||||
export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<section className="overview-band">
|
||||
<div>
|
||||
<div className="eyebrow">运行总览</div>
|
||||
<h2>控制台总览</h2>
|
||||
</div>
|
||||
<div className="overview-metrics">
|
||||
<StatusItem label="读路径" value="PG 只读" tone="neutral" />
|
||||
<StatusItem label="写路径" value="Admin API" tone="good" />
|
||||
<StatusItem label="执行策略" value="先预演" tone="warn" />
|
||||
</div>
|
||||
</section>
|
||||
<div className="command-grid">
|
||||
<Launcher icon={<Users />} title="账号管理" text="账号状态、会员、认证、会话。" href="/accounts" navigate={navigate} />
|
||||
<Launcher icon={<ShieldCheck />} title="超级群与频道" text="公开实体、成员计数、认证状态。" href="/channels" navigate={navigate} />
|
||||
<Launcher icon={<MessageSquareText />} title="消息审计" text="消息盒、update、outbox 状态。" href="/messages" navigate={navigate} />
|
||||
</div>
|
||||
<section className="work-strip">
|
||||
<div className="strip-item"><CheckCircle2 size={16} /><span>所有危险操作先预演</span></div>
|
||||
<div className="strip-item"><KeyRound size={16} /><span>浏览器不持有内部 token</span></div>
|
||||
<div className="strip-item"><Clock3 size={16} /><span>列表使用游标分页</span></div>
|
||||
<div className="strip-item"><FileJson size={16} /><span>详情页保留原始状态快照</span></div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Launcher({
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
href,
|
||||
navigate
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
text: string;
|
||||
href: string;
|
||||
navigate: Navigate;
|
||||
}) {
|
||||
return (
|
||||
<AppLink className="launcher" href={href} navigate={navigate}>
|
||||
<span className="launcher-icon">{icon}</span>
|
||||
<span className="launcher-copy">
|
||||
<strong>{title}</strong>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
100
cmd/telesrv-admin/web/src/pages/GroupMessageDetailPage.tsx
Normal file
100
cmd/telesrv-admin/web/src/pages/GroupMessageDetailPage.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { GroupMessageDetail } from "../types";
|
||||
|
||||
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.groupMessage(channelID, msgID));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [channelID, msgID]);
|
||||
|
||||
if (error) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label="加载群聊消息详情" />;
|
||||
}
|
||||
|
||||
const msg = detail.Message;
|
||||
return (
|
||||
<PageFrame
|
||||
title={`群聊消息 #${msg.ID}`}
|
||||
eyebrow="消息详情"
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> 返回群聊消息</button>}
|
||||
>
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">频道/群 {msg.ChannelID}</div>
|
||||
<div className="entity-subtitle">发送方 {msg.SenderUserID} · {formatUnix(msg.Date)}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{msg.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>存活</Badge>}
|
||||
{msg.Pinned && <Badge tone="warn">置顶</Badge>}
|
||||
{msg.Post && <Badge>频道帖子</Badge>}
|
||||
<Badge>pts {msg.PTS}</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label="消息 ID" value={String(msg.ID)} mono />
|
||||
<Summary label="频道 / 群" value={String(msg.ChannelID)} mono />
|
||||
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
|
||||
<Summary label="浏览" value={String(msg.ViewsCount)} />
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title="消息行" text="channel_messages 只读快照" />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="频道行" text="channels 只读快照" />
|
||||
<JsonBlock value={detail.ChannelJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="频道更新事件" text="durable channel_update_events" />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>PTS</th><th>数量</th><th>类型</th><th>消息 ID</th><th>发送方</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
{detail.UpdateEvents.map((row) => (
|
||||
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
|
||||
<td>{row.PTS}</td>
|
||||
<td>{row.PTSCount}</td>
|
||||
<td>{row.Type}</td>
|
||||
<td>{row.MessageID}</td>
|
||||
<td>{row.SenderUserID}</td>
|
||||
<td>{formatUnix(row.Date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="事件 JSON" />
|
||||
<div className="raw-grid">
|
||||
{detail.UpdateEvents.map((row) => (
|
||||
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
|
||||
))}
|
||||
{detail.UpdateEvents.length === 0 && <div className="empty-panel">无结果</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
119
cmd/telesrv-admin/web/src/pages/GroupMessagesPage.tsx
Normal file
119
cmd/telesrv-admin/web/src/pages/GroupMessagesPage.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { ChevronRight, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ChannelPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { channelKind, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelRow, GroupMessageListResponse } from "../types";
|
||||
|
||||
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [beforeID, setBeforeID] = useState("");
|
||||
const [limit, setLimit] = useState("100");
|
||||
const [data, setData] = useState<GroupMessageListResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setError("");
|
||||
if (!channel) {
|
||||
setError("请先搜索并选择超级群或频道");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
channel_id: String(channel.ID),
|
||||
limit
|
||||
});
|
||||
if (next && data?.rows.length) {
|
||||
const last = data.rows[data.rows.length - 1];
|
||||
params.set("before_date", String(last.Date));
|
||||
params.set("before_id", String(last.ID));
|
||||
setBeforeDate(String(last.Date));
|
||||
setBeforeID(String(last.ID));
|
||||
} else {
|
||||
if (beforeDate) params.set("before_date", beforeDate);
|
||||
if (beforeID) params.set("before_id", beforeID);
|
||||
}
|
||||
try {
|
||||
setData(await api.groupMessages(params));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function changeChannel(row: ChannelRow | null) {
|
||||
setChannel(row);
|
||||
setBeforeDate("");
|
||||
setBeforeID("");
|
||||
setData(null);
|
||||
}
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
|
||||
return (
|
||||
<PageFrame title="群聊消息" eyebrow="超级群 / 频道消息">
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<QueryPanel>
|
||||
<div className="message-selector-grid single">
|
||||
<ChannelPicker label="超级群 / 频道" value={channel} onChange={changeChannel} />
|
||||
</div>
|
||||
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
|
||||
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
|
||||
<button className="btn primary icon-text" type="submit"><Search size={15} /> 查询消息</button>
|
||||
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> 下一页</button> : null}
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="metric-row">
|
||||
<Metric label="当前页消息" value={String(rows.length)} />
|
||||
<Metric label="有媒体" value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
|
||||
<Metric label="频道帖子" value={String(rows.filter((row) => row.Post).length)} />
|
||||
<Metric label="频道 / 群" value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>消息 ID</th>
|
||||
<th>时间</th>
|
||||
<th>发送方</th>
|
||||
<th>From Peer</th>
|
||||
<th>PTS</th>
|
||||
<th>浏览</th>
|
||||
<th>状态</th>
|
||||
<th>正文</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={`${row.ChannelID}-${row.ID}`}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{formatUnix(row.Date)}</td>
|
||||
<td className="mono">{row.SenderUserID}</td>
|
||||
<td className="mono">{row.FromPeerType}:{row.FromPeerID}</td>
|
||||
<td>{row.PTS}</td>
|
||||
<td>{row.ViewsCount}</td>
|
||||
<td>
|
||||
{row.Deleted ? <Badge tone="danger">已删除</Badge> : row.Pinned ? <Badge tone="warn">置顶</Badge> : <Badge>存活</Badge>}
|
||||
</td>
|
||||
<td className="truncate">{row.Body}</td>
|
||||
<td>
|
||||
<button
|
||||
className="row-link"
|
||||
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
|
||||
>
|
||||
详情 <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={9} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
61
cmd/telesrv-admin/web/src/pages/LoginPage.tsx
Normal file
61
cmd/telesrv-admin/web/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert } from "../components/ui";
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
||||
const [secret, setSecret] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.login(secret);
|
||||
onLogin(result.actor);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-panel">
|
||||
<div className="login-head">
|
||||
<div className="brand brand-elevated">
|
||||
<span className="brand-mark">T</span>
|
||||
<span>
|
||||
<strong>telesrv</strong>
|
||||
<small>管理控制台</small>
|
||||
</span>
|
||||
</div>
|
||||
<span className="login-chip">本地访问</span>
|
||||
</div>
|
||||
<div className="login-copy">
|
||||
<h1>运维后台</h1>
|
||||
<p>输入凭据后进入控制台。</p>
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<form className="form-stack" onSubmit={submit}>
|
||||
<label>
|
||||
<span>管理员密码或 token</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="password"
|
||||
value={secret}
|
||||
autoComplete="current-password"
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn primary full" type="submit" disabled={busy}>
|
||||
{busy ? "登录中" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
116
cmd/telesrv-admin/web/src/pages/MessageDetailPage.tsx
Normal file
116
cmd/telesrv-admin/web/src/pages/MessageDetailPage.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { formatDate, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { MessageDetail } from "../types";
|
||||
|
||||
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<MessageDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.message(ownerUserID, msgID));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [ownerUserID, msgID]);
|
||||
|
||||
if (error) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label="加载消息详情" />;
|
||||
}
|
||||
|
||||
const msg = detail.Message;
|
||||
return (
|
||||
<PageFrame
|
||||
title={`消息 #${msg.BoxID}`}
|
||||
eyebrow="消息详情"
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> 返回私聊消息</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">所属 {msg.OwnerUserID} · 对端 {msg.PeerID}</div>
|
||||
<div className="entity-subtitle">发送方 {msg.FromUserID} · {formatUnix(msg.Date)}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{msg.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>存活</Badge>}
|
||||
<Badge>pts {msg.PTS}</Badge>
|
||||
<Badge>{msg.Outgoing ? "发出" : "收到"}</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label="消息盒 ID" value={String(msg.BoxID)} mono />
|
||||
<Summary label="私聊消息 ID" value={String(msg.PrivateMessageID)} mono />
|
||||
<Summary label="发送方" value={String(msg.MessageSenderID)} mono />
|
||||
<Summary label="时间" value={formatUnix(msg.Date)} />
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title="消息盒" text="message_boxes 只读快照" />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<div className="raw-grid">
|
||||
<section className="section-block">
|
||||
<SectionHead title="会话行" text="dialogs 只读快照" />
|
||||
<JsonBlock value={detail.DialogJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="私聊消息行" text="private_messages 只读快照" />
|
||||
<JsonBlock value={detail.PrivateJSON} />
|
||||
</section>
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title="更新事件" text="durable user_update_events" />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>PTS</th><th>数量</th><th>类型</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
|
||||
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="分发队列" text="在线/离线 dispatch_outbox" />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>ID</th><th>用户</th><th>PTS</th><th>类型</th><th>状态</th><th>尝试</th><th>更新时间</th></tr></thead>
|
||||
<tbody>
|
||||
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
|
||||
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">消息操作</div>
|
||||
<ActionButton
|
||||
label="删除此消息"
|
||||
icon={<Trash2 size={15} />}
|
||||
path="/api/actions/delete-messages"
|
||||
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}
|
||||
onDone={load}
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
157
cmd/telesrv-admin/web/src/pages/MessagesPage.tsx
Normal file
157
cmd/telesrv-admin/web/src/pages/MessagesPage.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { ChevronRight, History, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRow, MessageListResponse } from "../types";
|
||||
|
||||
export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [peer, setPeer] = useState<AccountRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [beforeID, setBeforeID] = useState("");
|
||||
const [limit, setLimit] = useState("100");
|
||||
const [ids, setIDs] = useState("");
|
||||
const [revoke, setRevoke] = useState(true);
|
||||
const [justClear, setJustClear] = useState(false);
|
||||
const [maxID, setMaxID] = useState("");
|
||||
const [maxBatches, setMaxBatches] = useState("1");
|
||||
const [data, setData] = useState<MessageListResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setError("");
|
||||
if (!owner || !peer) {
|
||||
setError("请先搜索并选择所属用户和对端用户");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
owner_user_id: String(owner.ID),
|
||||
peer_id: String(peer.ID),
|
||||
limit
|
||||
});
|
||||
if (next && data?.rows.length) {
|
||||
const last = data.rows[data.rows.length - 1];
|
||||
params.set("before_date", String(last.Date));
|
||||
params.set("before_id", String(last.BoxID));
|
||||
setBeforeDate(String(last.Date));
|
||||
setBeforeID(String(last.BoxID));
|
||||
} else {
|
||||
if (beforeDate) params.set("before_date", beforeDate);
|
||||
if (beforeID) params.set("before_id", beforeID);
|
||||
}
|
||||
try {
|
||||
setData(await api.messages(params));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function changeOwner(row: AccountRow | null) {
|
||||
setOwner(row);
|
||||
setBeforeDate("");
|
||||
setBeforeID("");
|
||||
setData(null);
|
||||
}
|
||||
|
||||
function changePeer(row: AccountRow | null) {
|
||||
setPeer(row);
|
||||
setBeforeDate("");
|
||||
setBeforeID("");
|
||||
setData(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame title="私聊消息" eyebrow="私聊消息盒">
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<QueryPanel>
|
||||
<div className="message-selector-grid">
|
||||
<UserPicker label="所属用户" value={owner} onChange={changeOwner} />
|
||||
<UserPicker label="对端用户" value={peer} onChange={changePeer} />
|
||||
</div>
|
||||
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
|
||||
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
|
||||
<button className="btn primary icon-text" type="submit"><Search size={15} /> 查询消息</button>
|
||||
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> 下一页</button> : null}
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="metric-row">
|
||||
<Metric label="当前页消息" value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label="已删除" value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
|
||||
<Metric label="发出消息" value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
|
||||
<Metric label="所属 / 对端" value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
|
||||
</div>
|
||||
<div className="operation-row">
|
||||
<div className="operation-box">
|
||||
<div className="operation-title"><Trash2 size={15} /> 删除指定消息</div>
|
||||
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder="消息 ID,逗号分隔" />
|
||||
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> 同步撤回</label>
|
||||
<ActionButton path="/api/actions/delete-messages" label="预演删除" payload={() => ({
|
||||
owner_user_id: owner?.ID ?? 0,
|
||||
peer_id: peer?.ID ?? 0,
|
||||
ids: parseIDs(ids),
|
||||
revoke
|
||||
})} />
|
||||
</div>
|
||||
<div className="operation-box">
|
||||
<div className="operation-title"><History size={15} /> 清空私聊历史</div>
|
||||
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder="max_id 截止消息" />
|
||||
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder="max_batches 批次数" />
|
||||
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> 同步撤回</label>
|
||||
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> 仅清本侧</label>
|
||||
<ActionButton path="/api/actions/delete-history" label="预演清历史" payload={() => ({
|
||||
owner_user_id: owner?.ID ?? 0,
|
||||
peer_id: peer?.ID ?? 0,
|
||||
max_id: toInt(maxID),
|
||||
max_batches: toInt(maxBatches),
|
||||
just_clear: justClear,
|
||||
revoke
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>消息 ID</th>
|
||||
<th>时间</th>
|
||||
<th>发送方</th>
|
||||
<th>方向</th>
|
||||
<th>PTS</th>
|
||||
<th>状态</th>
|
||||
<th>正文</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.rows.map((row) => (
|
||||
<tr key={`${row.OwnerUserID}-${row.BoxID}`}>
|
||||
<td className="mono">{row.BoxID}</td>
|
||||
<td>{formatUnix(row.Date)}</td>
|
||||
<td className="mono">{row.FromUserID}</td>
|
||||
<td>{row.Outgoing ? "发出" : "收到"}</td>
|
||||
<td>{row.PTS}</td>
|
||||
<td>{row.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>存活</Badge>}</td>
|
||||
<td className="truncate">{row.Body}</td>
|
||||
<td>
|
||||
<button
|
||||
className="row-link"
|
||||
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
|
||||
>
|
||||
详情 <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
52
cmd/telesrv-admin/web/src/pages/Routes.tsx
Normal file
52
cmd/telesrv-admin/web/src/pages/Routes.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { type Navigate, type RouteState } from "../routing";
|
||||
import { AccountDetailPage } from "./AccountDetailPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
import { ChannelsPage } from "./ChannelsPage";
|
||||
import { Dashboard } from "./Dashboard";
|
||||
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
||||
import { GroupMessagesPage } from "./GroupMessagesPage";
|
||||
import { MessageDetailPage } from "./MessageDetailPage";
|
||||
import { MessagesPage } from "./MessagesPage";
|
||||
|
||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||
if (accountID) {
|
||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||
}
|
||||
if (channelID) {
|
||||
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts") {
|
||||
return <AccountsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/channels") {
|
||||
return <ChannelsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
<MessageDetailPage
|
||||
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
|
||||
msgID={Number(route.search.get("msg_id") || "0")}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (route.path === "/messages/groups/detail") {
|
||||
return (
|
||||
<GroupMessageDetailPage
|
||||
channelID={Number(route.search.get("channel_id") || "0")}
|
||||
msgID={Number(route.search.get("msg_id") || "0")}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (route.path === "/messages/groups") {
|
||||
return <GroupMessagesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/messages" || route.path === "/messages/private") {
|
||||
return <MessagesPage navigate={navigate} />;
|
||||
}
|
||||
return <Dashboard navigate={navigate} />;
|
||||
}
|
||||
29
cmd/telesrv-admin/web/src/routing.ts
Normal file
29
cmd/telesrv-admin/web/src/routing.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export type Navigate = (href: string) => void;
|
||||
|
||||
export type RouteState = {
|
||||
href: string;
|
||||
path: string;
|
||||
search: URLSearchParams;
|
||||
};
|
||||
|
||||
export function currentRoute(): RouteState {
|
||||
return {
|
||||
href: `${window.location.pathname}${window.location.search}`,
|
||||
path: window.location.pathname,
|
||||
search: new URLSearchParams(window.location.search)
|
||||
};
|
||||
}
|
||||
|
||||
export function routeTitle(pathname: string): string {
|
||||
if (pathname.startsWith("/accounts")) return "账号管理";
|
||||
if (pathname.startsWith("/channels")) return "超级群与频道";
|
||||
if (pathname.startsWith("/messages")) return "消息审计";
|
||||
return "运维控制台";
|
||||
}
|
||||
|
||||
export function routeSubtitle(pathname: string): string {
|
||||
if (pathname.startsWith("/accounts")) return "控制台 / 账号";
|
||||
if (pathname.startsWith("/channels")) return "控制台 / 频道";
|
||||
if (pathname.startsWith("/messages")) return "控制台 / 消息";
|
||||
return "控制台 / 总览";
|
||||
}
|
||||
5
cmd/telesrv-admin/web/src/styles.css
Normal file
5
cmd/telesrv-admin/web/src/styles.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@import "./styles/01-foundation.css";
|
||||
@import "./styles/02-pages-and-forms.css";
|
||||
@import "./styles/03-entities-and-actions.css";
|
||||
@import "./styles/04-modal-and-login.css";
|
||||
@import "./styles/05-responsive.css";
|
||||
285
cmd/telesrv-admin/web/src/styles/01-foundation.css
Normal file
285
cmd/telesrv-admin/web/src/styles/01-foundation.css
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f3f5f7;
|
||||
--panel: #ffffff;
|
||||
--panel-subtle: #f8fafb;
|
||||
--panel-strong: #eef2f5;
|
||||
--line: #d9e1e8;
|
||||
--line-strong: #c2ccd6;
|
||||
--text: #101828;
|
||||
--muted: #667085;
|
||||
--muted-2: #98a2b3;
|
||||
--brand: #176d61;
|
||||
--brand-2: #245b9d;
|
||||
--good: #167447;
|
||||
--warn: #a15c07;
|
||||
--danger: #b42318;
|
||||
--sidebar: #11161d;
|
||||
--sidebar-soft: #1b222b;
|
||||
--sidebar-line: #2c3541;
|
||||
--focus: rgba(23, 109, 97, 0.16);
|
||||
--shadow: 0 18px 52px rgba(16, 24, 40, 0.14);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font: 13px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
grid-template-columns: 232px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px 12px;
|
||||
color: #eef2f6;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--sidebar-line);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.brand.compact {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-elevated .brand-mark {
|
||||
box-shadow: 0 8px 24px rgba(23, 109, 97, 0.26);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
background: var(--brand);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #aeb8c4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
padding: 0 8px;
|
||||
color: #8492a6;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-section-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 16px;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 10px;
|
||||
color: #8fa0b4;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-section-toggle:hover,
|
||||
.nav-section.active .nav-section-toggle {
|
||||
color: #ffffff;
|
||||
background: var(--sidebar-soft);
|
||||
border-color: #34404d;
|
||||
}
|
||||
|
||||
.nav-section-chevron {
|
||||
justify-self: end;
|
||||
color: #8fa0b4;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.nav-section.open .nav-section-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.nav-children {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 2px 0 2px 18px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 10px;
|
||||
color: #c6d0dc;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.nav-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
justify-self: center;
|
||||
background: #687789;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
color: #ffffff;
|
||||
background: var(--sidebar-soft);
|
||||
border-color: #34404d;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-dot {
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.sidebar-status {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.runtime-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
color: #cbd5df;
|
||||
background: #171d25;
|
||||
border: 1px solid #27313c;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.runtime-row strong {
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 66px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.page-actions,
|
||||
.section-action,
|
||||
.entity-badges,
|
||||
.row-actions,
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actor-pill {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
color: #344054;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px 24px 30px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
559
cmd/telesrv-admin/web/src/styles/02-pages-and-forms.css
Normal file
559
cmd/telesrv-admin/web/src/styles/02-pages-and-forms.css
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
.dashboard-layout,
|
||||
.stacked-sections {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.overview-band,
|
||||
.page-frame {
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.overview-band {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(420px, 0.9fr);
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.overview-band h2,
|
||||
.page-title-row h2,
|
||||
.section-head h2,
|
||||
.modal h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.overview-metrics,
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overview-metrics {
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.status-item,
|
||||
.metric,
|
||||
.summary-item {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.status-item span,
|
||||
.metric span,
|
||||
.summary-item span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-item strong,
|
||||
.metric strong,
|
||||
.summary-item strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-item.good,
|
||||
.metric.good {
|
||||
border-color: #afd8bf;
|
||||
}
|
||||
|
||||
.status-item.warn,
|
||||
.metric.warn {
|
||||
border-color: #e7c77e;
|
||||
}
|
||||
|
||||
.metric.danger {
|
||||
border-color: #efb4ad;
|
||||
}
|
||||
|
||||
.command-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.launcher {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 18px;
|
||||
min-height: 94px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.launcher:hover {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.launcher-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: #edf7f4;
|
||||
border: 1px solid #c9e2dc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.launcher-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.launcher-copy strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.launcher-copy span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.work-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.strip-item {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
color: #344054;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.page-frame {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.page-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.query-panel {
|
||||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message-query input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.message-selector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-selector-grid.single {
|
||||
grid-template-columns: minmax(320px, 620px);
|
||||
}
|
||||
|
||||
.entity-picker {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #344054;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.selected-entity {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
color: #0f3f38;
|
||||
background: #eef8f5;
|
||||
border: 1px solid #b9dcd3;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.selected-entity strong,
|
||||
.selected-entity span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-entity div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.selected-entity div span {
|
||||
color: #52606d;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 6px 0 9px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.picker-search input {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.picker-results {
|
||||
display: grid;
|
||||
max-height: 236px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.picker-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(120px, 1fr) minmax(120px, 1fr) auto;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.picker-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.picker-row:hover,
|
||||
.picker-row.selected {
|
||||
background: #f3f8f6;
|
||||
}
|
||||
|
||||
.picker-row strong,
|
||||
.picker-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-empty,
|
||||
.picker-error {
|
||||
padding: 9px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.picker-error {
|
||||
color: var(--danger);
|
||||
background: #fff2f0;
|
||||
border: 1px solid #efb4ad;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 190px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
}
|
||||
|
||||
.small-input {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.field-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field-inline span {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.searchbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(380px, 100%);
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.searchbox input {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
color: #1d2939;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f7f9fb;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
color: var(--muted-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
color: #ffffff;
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: #12594f;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
color: var(--danger);
|
||||
background: #fff7f5;
|
||||
border-color: #efb4ad;
|
||||
}
|
||||
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: #ffeceb;
|
||||
}
|
||||
|
||||
.btn.warn {
|
||||
color: var(--warn);
|
||||
background: #fff8ec;
|
||||
border-color: #e7c77e;
|
||||
}
|
||||
|
||||
.btn.warn:hover:not(:disabled) {
|
||||
background: #fff1d6;
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btn.primary:disabled,
|
||||
.btn.warn:disabled,
|
||||
.btn.danger:disabled {
|
||||
color: var(--muted-2);
|
||||
background: #f3f5f7;
|
||||
border-color: var(--line);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.compact-btn {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row-link,
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
color: var(--brand-2);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
height: 38px;
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
color: #475467;
|
||||
background: var(--panel-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
max-width: 380px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
color: #4f5b68;
|
||||
background: #f3f6f8;
|
||||
border: 1px solid #d7e0e8;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.good {
|
||||
color: var(--good);
|
||||
background: #eef8f2;
|
||||
border-color: #b9dcc7;
|
||||
}
|
||||
|
||||
.badge.danger {
|
||||
color: var(--danger);
|
||||
background: #fff2f0;
|
||||
border-color: #efb4ad;
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
color: var(--warn);
|
||||
background: #fff8e7;
|
||||
border-color: #e7c77e;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
254
cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css
Normal file
254
cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
.split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 330px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.split-main,
|
||||
.split-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.entity-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.entity-subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
color: #344054;
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-block,
|
||||
.action-dock,
|
||||
.surface {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.action-dock {
|
||||
position: sticky;
|
||||
top: 82px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dock-title {
|
||||
padding-bottom: 4px;
|
||||
color: #344054;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.action-dock > .btn,
|
||||
.action-dock .action-stack .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.duration-field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.duration-field span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.duration-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-stack .btn,
|
||||
.action-dock > .btn {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.authorization-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.authorization-table {
|
||||
min-width: 720px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.authorization-table th,
|
||||
.authorization-table td {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.device-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-text {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.device-actions-head {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.device-actions-cell {
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.device-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(82px, 1fr));
|
||||
gap: 6px;
|
||||
min-width: 178px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.device-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.operation-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.operation-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.operation-title {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.checkline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.checkline input {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
color: #8a251d;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #efb4ad;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.json-block {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
color: #d8e6f0;
|
||||
background: #141a22;
|
||||
border: 1px solid #2a3542;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.raw-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.loading-line {
|
||||
min-height: 80px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-panel {
|
||||
display: grid;
|
||||
min-height: 92px;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
261
cmd/telesrv-admin/web/src/styles/04-modal-and-login.css
Normal file
261
cmd/telesrv-admin/web/src/styles/04-modal-and-login.css
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(17, 24, 39, 0.52);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(760px, 100%);
|
||||
max-height: min(820px, calc(100vh - 48px));
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.command-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.command-step {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.command-step span {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.command-step.active {
|
||||
color: var(--brand);
|
||||
border-color: #a9d8ce;
|
||||
}
|
||||
|
||||
.command-step.done {
|
||||
color: var(--good);
|
||||
border-color: #b9dcc7;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field span,
|
||||
.form-stack span {
|
||||
color: #4b5563;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.command-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-preview .json-block {
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.preview-head,
|
||||
.result-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #344054;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-line {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-line span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.result-line strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.result-message {
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
justify-content: flex-end;
|
||||
padding: 12px 18px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
width: min(420px, 100%);
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-chip {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: var(--brand);
|
||||
background: #edf7f4;
|
||||
border: 1px solid #c9e2dc;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-copy p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-stack label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-stack input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.boot-screen {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.loader-bar {
|
||||
width: 180px;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background: #d7dde4;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.loader-bar::before {
|
||||
display: block;
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
content: "";
|
||||
background: var(--brand);
|
||||
animation: load 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes load {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
78
cmd/telesrv-admin/web/src/styles/05-responsive.css
Normal file
78
cmd/telesrv-admin/web/src/styles/05-responsive.css
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
@media (max-width: 1120px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sidebar-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview-band,
|
||||
.split-layout,
|
||||
.operation-row,
|
||||
.raw-grid,
|
||||
.message-selector-grid,
|
||||
.message-selector-grid.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-dock {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.content,
|
||||
.topbar {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.command-grid,
|
||||
.work-strip,
|
||||
.overview-metrics,
|
||||
.metric-row,
|
||||
.summary-grid,
|
||||
.command-steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.page-title-row,
|
||||
.entity-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input,
|
||||
.searchbox {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.picker-row,
|
||||
.selected-entity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
222
cmd/telesrv-admin/web/src/types.ts
Normal file
222
cmd/telesrv-admin/web/src/types.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
export type AccountRow = {
|
||||
ID: number;
|
||||
Phone: string;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
LastName: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Frozen: boolean;
|
||||
Reason: string;
|
||||
Verified: boolean;
|
||||
PremiumUntil: number;
|
||||
LastActiveAt: string;
|
||||
DeviceCount: number;
|
||||
};
|
||||
|
||||
export type RestrictionRow = {
|
||||
Frozen: boolean;
|
||||
Reason: string;
|
||||
Actor: string;
|
||||
CommandID: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type AuthorizationRow = {
|
||||
AuthKeyID: number;
|
||||
Hash: number;
|
||||
Layer: number;
|
||||
DeviceModel: string;
|
||||
Platform: string;
|
||||
SystemVersion: string;
|
||||
APIID: number;
|
||||
AppVersion: string;
|
||||
IP: string;
|
||||
PasswordPending: boolean;
|
||||
CreatedAt: string;
|
||||
ActiveAt: string;
|
||||
};
|
||||
|
||||
export type AuditLogRow = {
|
||||
ID: number;
|
||||
CommandID: string;
|
||||
Actor: string;
|
||||
Action: string;
|
||||
DryRun: boolean;
|
||||
Reason: string;
|
||||
Status: string;
|
||||
Error: string;
|
||||
Result: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type AccountDetail = {
|
||||
Account: AccountRow;
|
||||
About: string;
|
||||
LastSeenAt: number;
|
||||
Verified: boolean;
|
||||
Support: boolean;
|
||||
Bot: boolean;
|
||||
Restriction: RestrictionRow;
|
||||
HasRestriction: boolean;
|
||||
Authorizations: AuthorizationRow[];
|
||||
AuditLogs: AuditLogRow[];
|
||||
};
|
||||
|
||||
export type ChannelRow = {
|
||||
ID: number;
|
||||
AccessHash: number;
|
||||
CreatorUserID: number;
|
||||
Title: string;
|
||||
About: string;
|
||||
Username: string;
|
||||
Broadcast: boolean;
|
||||
Megagroup: boolean;
|
||||
Forum: boolean;
|
||||
Monoforum: boolean;
|
||||
Verified: boolean;
|
||||
Deleted: boolean;
|
||||
ParticipantsCount: number;
|
||||
AdminsCount: number;
|
||||
KickedCount: number;
|
||||
BannedCount: number;
|
||||
TopMessageID: number;
|
||||
PinnedMessageID: number;
|
||||
PTS: number;
|
||||
Date: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type ChannelDetail = {
|
||||
Channel: ChannelRow;
|
||||
ChannelJSON: string;
|
||||
AuditLogs: AuditLogRow[];
|
||||
};
|
||||
|
||||
export type MessageRow = {
|
||||
OwnerUserID: number;
|
||||
BoxID: number;
|
||||
PrivateMessageID: number;
|
||||
MessageSenderID: number;
|
||||
PeerID: number;
|
||||
FromUserID: number;
|
||||
Date: number;
|
||||
Outgoing: boolean;
|
||||
Body: string;
|
||||
PTS: number;
|
||||
Deleted: boolean;
|
||||
Media: string;
|
||||
};
|
||||
|
||||
export type GroupMessageRow = {
|
||||
ChannelID: number;
|
||||
ID: number;
|
||||
SenderUserID: number;
|
||||
FromPeerType: string;
|
||||
FromPeerID: number;
|
||||
Date: number;
|
||||
Post: boolean;
|
||||
Body: string;
|
||||
PTS: number;
|
||||
Deleted: boolean;
|
||||
Media: string;
|
||||
ViewsCount: number;
|
||||
EditDate: number;
|
||||
Pinned: boolean;
|
||||
};
|
||||
|
||||
export type UpdateEventRow = {
|
||||
PTS: number;
|
||||
PTSCount: number;
|
||||
Type: string;
|
||||
Date: number;
|
||||
JSON: string;
|
||||
};
|
||||
|
||||
export type ChannelUpdateEventRow = {
|
||||
PTS: number;
|
||||
PTSCount: number;
|
||||
Type: string;
|
||||
MessageID: number;
|
||||
Date: number;
|
||||
SenderUserID: number;
|
||||
JSON: string;
|
||||
};
|
||||
|
||||
export type OutboxRow = {
|
||||
ID: number;
|
||||
TargetUserID: number;
|
||||
PTS: number;
|
||||
EventType: string;
|
||||
Status: string;
|
||||
Attempts: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type MessageDetail = {
|
||||
Message: MessageRow;
|
||||
MessageJSON: string;
|
||||
DialogJSON: string;
|
||||
PrivateJSON: string;
|
||||
UpdateEvents: UpdateEventRow[];
|
||||
Outbox: OutboxRow[];
|
||||
};
|
||||
|
||||
export type GroupMessageDetail = {
|
||||
Message: GroupMessageRow;
|
||||
MessageJSON: string;
|
||||
ChannelJSON: string;
|
||||
UpdateEvents: ChannelUpdateEventRow[];
|
||||
};
|
||||
|
||||
export type CommandResult = {
|
||||
command_id: string;
|
||||
action: string;
|
||||
status: string;
|
||||
already_executed: boolean;
|
||||
dry_run: boolean;
|
||||
target_user_id?: number;
|
||||
target_peer?: unknown;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AccountListResponse = {
|
||||
query: string;
|
||||
limit: number;
|
||||
rows: AccountRow[];
|
||||
has_more: boolean;
|
||||
next_before_id: number;
|
||||
next_before_active_us: number;
|
||||
listing: boolean;
|
||||
};
|
||||
|
||||
export type ChannelListResponse = {
|
||||
query: string;
|
||||
limit: number;
|
||||
rows: ChannelRow[];
|
||||
has_more: boolean;
|
||||
next_before_id: number;
|
||||
next_before_updated_us: number;
|
||||
listing: boolean;
|
||||
};
|
||||
|
||||
export type MessageListResponse = {
|
||||
owner_user_id: number;
|
||||
peer_id: number;
|
||||
before_date: number;
|
||||
before_id: number;
|
||||
limit: number;
|
||||
rows: MessageRow[];
|
||||
};
|
||||
|
||||
export type GroupMessageListResponse = {
|
||||
channel_id: number;
|
||||
before_date: number;
|
||||
before_id: number;
|
||||
limit: number;
|
||||
rows: GroupMessageRow[];
|
||||
};
|
||||
20
cmd/telesrv-admin/web/tsconfig.json
Normal file
20
cmd/telesrv-admin/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
cmd/telesrv-admin/web/vite.config.ts
Normal file
16
cmd/telesrv-admin/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
port: 2410,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:2400"
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue