fixes
This commit is contained in:
parent
21a0856587
commit
e8dc967e6a
26 changed files with 1373 additions and 481 deletions
|
|
@ -954,27 +954,31 @@ ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.
|
|||
return groups, hasMore, nil
|
||||
}
|
||||
|
||||
// BroadcastRow is one system-broadcast campaign, with sent/failed counts
|
||||
// derived live from broadcast_recipients (never stored, so they can't drift).
|
||||
// BroadcastRow is one system-broadcast campaign. SentCount/FailedCount/
|
||||
// MaterializedCount are maintained incrementally by the delivery worker as
|
||||
// it closes out each recipient row (see internal/app/broadcast); for an
|
||||
// "all"-mode campaign still enumerating, TargetCount grows until
|
||||
// EnumerationDone.
|
||||
type BroadcastRow struct {
|
||||
ID int64
|
||||
Message string
|
||||
TargetMode string
|
||||
TotalCount int
|
||||
SentCount int
|
||||
FailedCount int
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
Message string
|
||||
TargetMode string
|
||||
TargetCount int64
|
||||
MaterializedCount int64
|
||||
SentCount int64
|
||||
FailedCount int64
|
||||
EnumerationDone bool
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const broadcastRowColumns = `
|
||||
b.id, b.message, b.target_mode, b.total_count, b.created_by, b.created_at,
|
||||
count(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
|
||||
count(*) FILTER (WHERE r.status = 'failed')::int AS failed_count`
|
||||
b.id, b.message, b.target_mode, b.target_count, b.materialized_count,
|
||||
b.sent_count, b.failed_count, b.enumeration_done, b.created_by, b.created_at`
|
||||
|
||||
func scanBroadcastRow(row interface{ Scan(...any) error }, item *BroadcastRow) error {
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt,
|
||||
&item.SentCount, &item.FailedCount)
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TargetCount, &item.MaterializedCount,
|
||||
&item.SentCount, &item.FailedCount, &item.EnumerationDone, &item.CreatedBy, &item.CreatedAt)
|
||||
}
|
||||
|
||||
// ListBroadcasts pages campaigns newest-first.
|
||||
|
|
@ -985,9 +989,7 @@ func (s *readStore) ListBroadcasts(ctx context.Context, beforeID int64, limit in
|
|||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+broadcastRowColumns+`
|
||||
FROM broadcasts b
|
||||
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
|
||||
WHERE $1::bigint = 0 OR b.id < $1
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC
|
||||
LIMIT $2`, beforeID, limit+1)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/hoststats"
|
||||
|
|
@ -303,6 +305,10 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||
// tells "the old admin process died and a new one answered" apart
|
||||
// from "the old one is just slow to respond".
|
||||
"boot_id": bootID,
|
||||
// api_layer is the MTProto TL schema layer this server binary speaks
|
||||
// (tg.Layer), shown in the sidebar footer above the build/commit line
|
||||
// so an operator can tell at a glance which protocol layer is live.
|
||||
"api_layer": tg.Layer,
|
||||
// build is this admin binary's own commit -- shown under "Version"
|
||||
// in the sidebar footer so an operator can tell at a glance which
|
||||
// build is actually running, independent of the app version string.
|
||||
|
|
@ -936,10 +942,13 @@ type createBroadcastAPIRequest struct {
|
|||
UserIDs []int64 `json:"user_ids,omitempty"`
|
||||
}
|
||||
|
||||
// handleCreateBroadcastAPI resolves "all users" into an explicit id list
|
||||
// before forwarding to the admin API: the admin service always receives an
|
||||
// already-resolved recipient list, never "every user" as a live concept it
|
||||
// would have to know how to enumerate itself.
|
||||
// handleCreateBroadcastAPI forwards a broadcast create straight to the admin
|
||||
// API. "all" mode is no longer pre-resolved into an explicit id list here:
|
||||
// the admin service snapshots the current eligible user set itself and the
|
||||
// broadcast worker enumerates it incrementally, so "every user" never has
|
||||
// to cross this boundary (or the one after it) as a potentially huge id
|
||||
// slice. Only "selected" mode carries UserIDs, already an operator-picked
|
||||
// list bounded by domain.MaxBroadcastSelectedRecipients.
|
||||
func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body createBroadcastAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
|
|
@ -947,16 +956,7 @@ func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
userIDs := body.UserIDs
|
||||
if body.TargetMode == "all" {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
all, err := s.read.ListAllAccountIDs(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
userIDs = all
|
||||
userIDs = nil
|
||||
}
|
||||
req := admin.CreateBroadcastRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-broadcast"),
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-CwTwvGWj.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-TfcI68oK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function App() {
|
|||
|
||||
return (
|
||||
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
|
||||
<Shell actor={session.actor} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Shell actor={session.actor} apiLayer={session.api_layer} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
</PermissionsProvider>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import { MultiUserPicker } from "./EntityPicker";
|
|||
type TargetMode = "all" | "selected";
|
||||
|
||||
// CreateBroadcastModal composes the message and target list, then hands off to
|
||||
// ActionButton for the usual dry-run/confirm flow. "All users" is resolved to an
|
||||
// explicit id list server-side (cmd/telesrv-admin/server.go), not here -- the
|
||||
// picker only ever deals with an actual, visible list of accounts.
|
||||
// ActionButton for the usual dry-run/confirm flow. "All users" is never
|
||||
// resolved into an id list at all -- the admin service snapshots the
|
||||
// current eligible user set itself and a background worker enumerates it
|
||||
// incrementally, so this only ever sends user_ids for "selected" mode,
|
||||
// where the picker deals with an actual, visible list of accounts.
|
||||
export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [targetMode, setTargetMode] = useState<TargetMode>("all");
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export function BootScreen() {
|
|||
|
||||
export function Shell({
|
||||
actor,
|
||||
apiLayer,
|
||||
build,
|
||||
route,
|
||||
navigate,
|
||||
|
|
@ -48,6 +49,7 @@ export function Shell({
|
|||
children
|
||||
}: {
|
||||
actor: string;
|
||||
apiLayer?: number;
|
||||
build?: { commit: string; short_commit: string; dirty: boolean; build_time: string };
|
||||
route: RouteState;
|
||||
navigate: Navigate;
|
||||
|
|
@ -178,6 +180,9 @@ export function Shell({
|
|||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className="sidebar-label">{"Version: O7"}</span>
|
||||
{typeof apiLayer === "number" && (
|
||||
<span className="sidebar-label sidebar-api-layer">{`API layer: ${apiLayer}`}</span>
|
||||
)}
|
||||
{build?.short_commit && (
|
||||
<span className="sidebar-label sidebar-build" title={build.commit + (build.dirty ? " (uncommitted changes)" : "")}>
|
||||
{`Build: ${build.short_commit}${build.dirty ? "+" : ""}`}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function BroadcastsPage() {
|
|||
}, []);
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const inFlight = rows.filter((row) => row.SentCount + row.FailedCount < row.TotalCount).length;
|
||||
const inFlight = rows.filter((row) => !row.EnumerationDone || row.SentCount + row.FailedCount < row.TargetCount).length;
|
||||
const canGoPrev = history.length > 0 && !busy;
|
||||
const canGoNext = Boolean(data?.has_more) && !busy;
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ export function BroadcastsPage() {
|
|||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const delivered = row.SentCount + row.FailedCount;
|
||||
const done = row.TotalCount > 0 && delivered >= row.TotalCount;
|
||||
const done = row.EnumerationDone && row.TargetCount > 0 && delivered >= row.TargetCount;
|
||||
return (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
|
|
@ -118,7 +118,7 @@ export function BroadcastsPage() {
|
|||
<td>{row.TargetMode === "all" ? <Badge tone="warn">{"All users"}</Badge> : <Badge>{"Selected"}</Badge>}</td>
|
||||
<td>{row.SentCount}</td>
|
||||
<td>{row.FailedCount > 0 ? <Badge tone="danger">{row.FailedCount}</Badge> : row.FailedCount}</td>
|
||||
<td>{row.TotalCount}</td>
|
||||
<td>{row.TargetCount}</td>
|
||||
<td>{row.CreatedBy || "-"}</td>
|
||||
<td>
|
||||
{formatDate(row.CreatedAt)}
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@ export type AdminSession = {
|
|||
// comment. Used by Server Settings' Restart/Update flow to detect a
|
||||
// genuinely new admin process after asking it to bounce.
|
||||
boot_id?: string;
|
||||
// The MTProto TL schema layer this server binary speaks -- shown in the
|
||||
// sidebar footer above the build/commit line.
|
||||
api_layer?: number;
|
||||
// This admin binary's own build -- shown under "Version" in the sidebar
|
||||
// footer so an operator can tell which build is actually running.
|
||||
build?: {
|
||||
|
|
@ -794,9 +797,11 @@ export type BroadcastRow = {
|
|||
ID: number;
|
||||
Message: string;
|
||||
TargetMode: string;
|
||||
TotalCount: number;
|
||||
TargetCount: number;
|
||||
MaterializedCount: number;
|
||||
SentCount: number;
|
||||
FailedCount: number;
|
||||
EnumerationDone: boolean;
|
||||
CreatedBy: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1705,8 +1705,12 @@ func run(logger *zap.Logger) error {
|
|||
// all/selected users) are delivered from the same kind of durable outbox as
|
||||
// applicant notifications above: an admin creating one for every user must
|
||||
// not wait on however long sending to all of them takes.
|
||||
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
|
||||
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
|
||||
go broadcastapp.NewWorker(broadcastService, broadcastapp.WorkerConfig{
|
||||
Interval: cfg.BroadcastWorkerInterval,
|
||||
Lease: cfg.BroadcastWorkerLease,
|
||||
MaterializeBatch: cfg.BroadcastWorkerMaterializeBatch,
|
||||
DeliveryBatch: cfg.BroadcastWorkerBatch,
|
||||
}, logger.Named("broadcast").Named("delivery")).Run(ctx)
|
||||
moderationActionOptions := []moderationapp.ActionExecutorOption{
|
||||
moderationapp.WithAccountDeletionNotifier(router),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue