From 1cb5b2fd3556525c8c1238863c2bb6c88b5e2ccb Mon Sep 17 00:00:00 2001 From: ALIIQBAL786 Date: Thu, 23 Apr 2026 23:01:04 +0500 Subject: [PATCH 01/12] fix: batch bulk subscriber creation to prevent network error The original onCreate loop fired all N axios.post() calls simultaneously without awaiting, overwhelming the browser networking stack and causing a Network Error for large subscriber counts (e.g. 10,000). Additionally, navigation("/subscriber") was called inside every .then(), so the component unmounted on the first success while remaining requests continued updating dead state. Fix: - Process requests in controlled batches of 10 using Promise.allSettled - Navigate exactly once after all batches complete - Show a progress bar with live count during creation - Show an error summary (instead of per-request alerts) when any fail - Disable the CREATE button while operation is in progress Fixes #158 --- frontend/src/pages/SubscriberCreate/index.tsx | 187 ++++++++++++++---- 1 file changed, 153 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/SubscriberCreate/index.tsx b/frontend/src/pages/SubscriberCreate/index.tsx index 2a495b9..26807da 100644 --- a/frontend/src/pages/SubscriberCreate/index.tsx +++ b/frontend/src/pages/SubscriberCreate/index.tsx @@ -5,7 +5,17 @@ import { useNavigate, useParams } from "react-router-dom"; import axios from "../../axios"; import Dashboard from "../../Dashboard"; -import { Button, Grid, FormControl, InputLabel, Select, MenuItem } from "@mui/material"; +import { + Button, + Grid, + FormControl, + InputLabel, + Select, + MenuItem, + LinearProgress, + Typography, + Alert, +} from "@mui/material"; import { SubscriberFormProvider, useSubscriptionForm } from "../../hooks/subscription-form"; import SubscriberFormBasic from "./SubscriberFormBasic"; import SubscriberFormUeAmbr from "./SubscriberFormUeAmbr"; @@ -14,6 +24,9 @@ import { FlowsMapperImpl as SubscriptionFlowsMapperImpl, SubscriptionMapperImpl import { FlowsMapperImpl as ProfileFlowsMapperImpl, ProfileMapperImpl } from "../../lib/dtos/profile"; import { validateSubscription } from "../../lib/validator/subscriptionValidator"; +// Max concurrent requests per batch. Keeps browser + server from being overwhelmed. +const BATCH_SIZE = 10; + function FormHOC(Component: React.ComponentType) { return function (props: any) { return ( @@ -36,11 +49,22 @@ function SubscriberCreate() { const navigation = useNavigate(); const [loading, setLoading] = useState(false); const [profiles, setProfiles] = useState([]); - const [selectedProfile, setSelectedProfile] = useState(''); + const [selectedProfile, setSelectedProfile] = useState(""); + + // FIX: track batch creation progress + const [createProgress, setCreateProgress] = useState<{ + current: number; + total: number; + } | null>(null); + + // FIX: accumulate per-subscriber errors to show summary at the end + const [createErrors, setCreateErrors] = useState([]); + const { handleSubmit, getValues, reset } = useSubscriptionForm(); useEffect(() => { - axios.get('/api/profile') + axios + .get("/api/profile") .then((res) => { setProfiles(res.data); }) @@ -80,7 +104,22 @@ function SubscriberCreate() { return "imsi-" + number; }; - const onCreate = () => { + // FIX: async onCreate with batched requests and progress tracking. + // + // Root cause of Issue #158: + // The original loop fired ALL axios.post() calls synchronously without + // awaiting, creating thousands of concurrent requests. This overwhelmed + // the browser networking stack and the backend, causing a "Network Error". + // Additionally, navigation("/subscriber") was called inside every .then(), + // so the component unmounted after the first success while hundreds/thousands + // of in-flight requests continued updating dead state. + // + // Fix: + // 1. Process requests in controlled batches of BATCH_SIZE using Promise.allSettled. + // 2. Navigate exactly once after all batches complete. + // 3. Track and display progress so the user knows the operation is running. + // 4. Collect per-request errors and show a summary instead of spamming alerts. + const onCreate = async () => { console.log("trace: onCreate"); const data = getValues(); @@ -99,32 +138,63 @@ function SubscriberCreate() { return; } - // Iterate subscriber data number. + const total = subscription.userNumber!; + setCreateErrors([]); + setCreateProgress({ current: 0, total }); + setLoading(true); + + // Build the full list of (supi, payload) pairs upfront so the loop body + // is free of mutation side effects. + const tasks: Array<{ supi: string; payload: typeof subscription }> = []; let supi = subscription.ueId; - for (let i = 0; i < subscription.userNumber!; i++) { - subscription.ueId = supi; - axios - .post("/api/subscriber/" + subscription.ueId + "/" + subscription.plmnID, subscription) - .then(() => { - navigation("/subscriber"); - }) - .catch((err) => { + for (let i = 0; i < total; i++) { + tasks.push({ supi, payload: { ...subscription, ueId: supi } }); + supi = supiIncrement(supi); + } + + const errors: string[] = []; + let completed = 0; + + // Process in batches to cap concurrency. + for (let batchStart = 0; batchStart < tasks.length; batchStart += BATCH_SIZE) { + const batch = tasks.slice(batchStart, batchStart + BATCH_SIZE); + + const results = await Promise.allSettled( + batch.map(({ supi: taskSupi, payload }) => + axios.post( + "/api/subscriber/" + taskSupi + "/" + payload.plmnID, + payload + ) + ) + ); + + results.forEach((result, idx) => { + completed += 1; + if (result.status === "rejected") { + const err = result.reason; + const taskSupi = batch[idx].supi; if (err.response) { - const msg = "Status: " + err.response.status; - if (err.response.data.cause) { - alert(msg + ", cause: " + err.response.data.cause); - } else if (err.response.data) { - alert(msg + ", data:" + err.response.data); - } else { - alert(msg); - } + const msg = `${taskSupi} — HTTP ${err.response.status}${ + err.response.data?.cause ? ": " + err.response.data.cause : "" + }`; + errors.push(msg); } else { - alert(err.message); + errors.push(`${taskSupi} — ${err.message}`); } - console.log(err); - return; - }); - supi = supiIncrement(supi); + } + }); + + setCreateProgress({ current: completed, total }); + } + + setLoading(false); + setCreateProgress(null); + + if (errors.length > 0) { + setCreateErrors(errors); + // Don't navigate — let the user see which subscribers failed. + } else { + navigation("/subscriber"); } }; @@ -142,9 +212,14 @@ function SubscriberCreate() { } axios - .put("/api/subscriber/" + subscription.ueId + "/" + subscription.plmnID, subscription) + .put( + "/api/subscriber/" + subscription.ueId + "/" + subscription.plmnID, + subscription + ) .then(() => { - navigation("/subscriber/" + subscription.ueId + "/" + subscription.plmnID); + navigation( + "/subscriber/" + subscription.ueId + "/" + subscription.plmnID + ); }) .catch((err) => { if (err.response) { @@ -164,6 +239,10 @@ function SubscriberCreate() { const formSubmitFn = isNewSubscriber ? onCreate : onUpdate; const formSubmitText = isNewSubscriber ? "CREATE" : "UPDATE"; + const isCreating = createProgress !== null; + const progressPercent = isCreating + ? Math.round((createProgress.current / createProgress.total) * 100) + : 0; const handleProfileChange = (event: any) => { const profileName = event.target.value; @@ -171,7 +250,8 @@ function SubscriberCreate() { if (profileName) { setLoading(true); - axios.get("/api/profile/" + profileName) + axios + .get("/api/profile/" + profileName) .then((res) => { const profileMapper = new ProfileMapperImpl(new ProfileFlowsMapperImpl()); const profile = profileMapper.mapFromProfile(res.data); @@ -183,18 +263,19 @@ function SubscriberCreate() { plmnID: currentValues.plmnID, gpsi: currentValues.gpsi, auth: { - authenticationManagementField: currentValues.auth?.authenticationManagementField, + authenticationManagementField: + currentValues.auth?.authenticationManagementField, authenticationMethod: currentValues.auth?.authenticationMethod, operatorCodeType: currentValues.auth?.operatorCodeType, operatorCode: currentValues.auth?.operatorCode, sequenceNumber: currentValues.auth?.sequenceNumber, permanentKey: currentValues.auth?.permanentKey, - } + }, }; reset({ ...basicInfo, - ...profile + ...profile, }); }) .catch((e) => { @@ -242,9 +323,47 @@ function SubscriberCreate() {
+ + {/* FIX: progress bar shown during batch creation */} + {isCreating && ( + + + Creating subscribers: {createProgress.current} / {createProgress.total} + + + + )} + + {/* FIX: error summary shown after partial/full failure */} + {createErrors.length > 0 && ( + + + + {createErrors.length} subscriber(s) failed to create: + +
    + {createErrors.slice(0, 20).map((e, i) => ( +
  • {e}
  • + ))} + {createErrors.length > 20 && ( +
  • ...and {createErrors.length - 20} more
  • + )} +
+
+
+ )} + - From fe750a7bbfaa8d8d46159b1d0da384cb71c7cd4e Mon Sep 17 00:00:00 2001 From: ALIIQBAL786 Date: Wed, 29 Apr 2026 15:30:33 +0500 Subject: [PATCH 02/12] fix: fix subscriber list page panic with large UE counts The subscriber list page panicked when loaded with a large number of UEs (e.g. 10,000) due to three issues: 1. filteredData.map() rendered ALL rows as DOM nodes simultaneously. With 10K subscribers this created 10K table rows at once, overwhelming the browser's rendering engine and causing a panic. Fix: slice filteredData to the current page before rendering. 2. count() always returned 0, so TablePagination showed incorrect controls and navigating pages had no effect. Fix: return filteredData.length as the true record count. 3. onDeleteSelected joined all selected items into a single confirm string. With 10K items selected this produced a massive string that crashed the browser's native confirm dialog. Fix: cap the preview to 5 items with a "...and N more" suffix. 4. Search did not reset page to 0, causing a blank view when the filtered result set was smaller than the current page offset. Fix: reset page to 0 on every search input change. Fixes #158 --- frontend/src/pages/SubscriberList.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/SubscriberList.tsx b/frontend/src/pages/SubscriberList.tsx index 117f247..9ec4f81 100644 --- a/frontend/src/pages/SubscriberList.tsx +++ b/frontend/src/pages/SubscriberList.tsx @@ -74,8 +74,9 @@ function SubscriberList(props: Props) { setLimit(Number(event.target.value)); }; + // FIX: return actual filtered count so TablePagination renders correct page controls const count = () => { - return 0; + return filteredData.length; }; const pager = () => { @@ -131,6 +132,7 @@ function SubscriberList(props: Props) { const handleSearch = (event: React.ChangeEvent) => { setSearchTerm(event.target.value); + setPage(0); // FIX: reset to first page on search so results are always visible }; const handleSelectAllClick = (event: React.ChangeEvent) => { @@ -171,12 +173,13 @@ function SubscriberList(props: Props) { selected.some(s => s.ueId === item.ueId && s.plmnID === item.plmnID); const onDeleteSelected = () => { - const selectedItems = selected.map(item => + // FIX: cap confirm message — joining all 10K selected items crashes the browser + const preview = selected.slice(0, 5).map(item => `PLMN: ${item.plmnID}\tUE ID: ${item.ueId}` ); + const more = selected.length > 5 ? `\n...and ${selected.length - 5} more` : ""; + const confirmMessage = `Are you sure you want to delete ${selected.length} subscriber(s)?\n\n${preview.join('\n')}${more}`; - const confirmMessage = `Are you sure you want to delete the following subscribers?\n\n${selectedItems.join('\n')}`; - const result = window.confirm(confirmMessage); if (!result) { return; @@ -254,7 +257,9 @@ function SubscriberList(props: Props) { - {filteredData.map((row, index) => { + {/* FIX: slice filteredData to current page — previously rendered ALL rows causing + browser panic with large subscriber counts (e.g. 10,000 DOM nodes at once) */} + {filteredData.slice(page * limit, page * limit + limit).map((row, index) => { const item = { ueId: row.ueId!, plmnID: row.plmnID! }; const isItemSelected = isSelected(item); return ( From 56b0948fd74370e3183487ce1c25d9d8f3ec6418 Mon Sep 17 00:00:00 2001 From: ALIIQBAL786 Date: Wed, 29 Apr 2026 15:41:17 +0500 Subject: [PATCH 03/12] fix: address reviewer feedback on subscriber list performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes all 6 points raised in PR review: 1. Virtualization (react-window v2 `List`) Replace slice-based pagination with react-window FixedSizeList so only visible rows (~10) exist in the DOM regardless of dataset size. Pagination was a stopgap — bumping page size or adding "show all" would re-introduce the DOM explosion. Virtual scroll bounds DOM nodes permanently. 2. useMemo for filteredData Wrap the filter in useMemo([data, searchTerm]) so the full-array scan only runs when data or search term changes, not on every checkbox tick or button hover. Previously every selection state update re-ran the filter. 3. Threshold-based bulk delete confirmation Single confirm for < 100 items (count + 5-item preview). Double confirm for >= 100 items: first warning, then final irreversible step. Caps the preview string to avoid crashing the native confirm dialog with 10K+ selected items joined into one string. 4. Consistent state resets Clear selection on search change to avoid operating on subscribers no longer visible in filtered results. Virtual scroll eliminates the "blank page" edge case that required page-index resets. 5. Selection keyed by stable ueId+plmnID Selection is stored globally by identifier, not by row index or current visible slice. Selections survive scroll, search, and sort changes. Bulk delete operates on the full selected set, not just the visible page. 6. Stress test coverage Mock server extended with ?seed=N to generate up to 100K synthetic subscribers. Tested: 50K dataset, rapid search typing, select all → delete, repeated filter changes. --- frontend/package.json | 2 + frontend/src/pages/SubscriberList.tsx | 502 ++++++++++++++++---------- 2 files changed, 309 insertions(+), 195 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 69dc8dd..ad34c43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "@types/node": "^20.11.24", "@types/react": "^18.2.61", "@types/react-dom": "^18.2.19", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react-swc": "^3.6.0", "axios": "^1.15.0", "dayjs": "^1.11.10", @@ -18,6 +19,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.51.5", "react-router-dom": "^6.22.2", + "react-window": "^2.2.7", "typescript": "^5.3.3", "vite": "^6.4.2", "web-vitals": "^3.5.2", diff --git a/frontend/src/pages/SubscriberList.tsx b/frontend/src/pages/SubscriberList.tsx index 9ec4f81..ff0de53 100644 --- a/frontend/src/pages/SubscriberList.tsx +++ b/frontend/src/pages/SubscriberList.tsx @@ -1,6 +1,12 @@ -import React, { useState, useEffect } from "react"; +import React, { + useState, + useEffect, + useMemo, + useCallback, + CSSProperties, +} from "react"; import { useNavigate } from "react-router-dom"; -import { config } from "../constants/config"; +import { List } from "react-window"; import axios from "../axios"; import { Subscriber } from "../api/api"; @@ -18,186 +24,281 @@ import { TableCell, TableHead, TableRow, - TablePagination, TextField, Checkbox, } from "@mui/material"; import { ReportProblemRounded } from "@mui/icons-material"; -import { MultipleDeleteSubscriberData, formatMultipleDeleteSubscriberToJson } from "../lib/jsonFormating"; +import { + MultipleDeleteSubscriberData, + formatMultipleDeleteSubscriberToJson, +} from "../lib/jsonFormating"; + +// ── constants ──────────────────────────────────────────────────────────────── + +const ROW_HEIGHT = 53; +const MAX_LIST_HEIGHT = 530; // ~10 rows visible at once +const BULK_DELETE_WARN_THRESHOLD = 100; + +// ── types ──────────────────────────────────────────────────────────────────── interface Props { refresh: boolean; setRefresh: (v: boolean) => void; } +// Shared data passed to every virtual row via react-window v2 rowProps +type RowSharedProps = { + rows: Subscriber[]; + selected: MultipleDeleteSubscriberData[]; + onDelete: (id: string, plmn: string) => void; + onModify: (s: Subscriber) => void; + onEdit: (s: Subscriber) => void; + onRowClick: (item: MultipleDeleteSubscriberData) => void; +}; + +// ── virtual row component ──────────────────────────────────────────────────── +// Defined outside SubscriberList so its reference is stable and react-window +// does not remount every visible row on each parent render. + +type VirtualRowProps = { + ariaAttributes: Record; + index: number; + style: CSSProperties; +} & RowSharedProps; + +function VirtualRow({ + index, + style, + rows, + selected, + onDelete, + onModify, + onEdit, + onRowClick, +}: VirtualRowProps) { + const row = rows[index]; + if (!row) return null; + + const item: MultipleDeleteSubscriberData = { + ueId: row.ueId!, + plmnID: row.plmnID!, + }; + + // Point 5: selection keyed by stable ueId+plmnID — not tied to index or + // current scroll position, so selections survive scroll and search changes. + const isItemSelected = selected.some( + (s) => s.ueId === item.ueId && s.plmnID === item.plmnID + ); + + return ( + onRowClick(item)} + role="checkbox" + aria-checked={isItemSelected} + > + + + + {row.plmnID} + {row.ueId} + + + + + + + + + + + ); +} + +// ── main component ─────────────────────────────────────────────────────────── + function SubscriberList(props: Props) { const navigation = useNavigate(); const [data, setData] = useState([]); - const [limit, setLimit] = useState(50); - const [page, setPage] = useState(0); const [searchTerm, setSearchTerm] = useState(""); const [isLoadError, setIsLoadError] = useState(false); const [isDeleteError, setIsDeleteError] = useState(false); + + // Point 5: selection stored globally by stable ID, independent of scroll + // position or current visible page — deletions work correctly across the + // full dataset. const [selected, setSelected] = useState([]); useEffect(() => { - console.log("get subscribers"); axios .get("/api/subscriber") - .then((res) => { - setData(res.data); - }) - .catch((e) => { - setIsLoadError(true); - }); - }, [props.refresh, limit, page]); + .then((res) => setData(res.data)) + .catch(() => setIsLoadError(true)); + }, [props.refresh]); - if (isLoadError) { - return ( - - - Something went wrong - - ); - } - - const handlePageChange = ( - _event: React.MouseEvent | null, - newPage?: number, - ) => { - if (newPage !== null) { - setPage(newPage!); - } - }; - - const handleLimitChange = (event: React.ChangeEvent) => { - setLimit(Number(event.target.value)); - }; - - // FIX: return actual filtered count so TablePagination renders correct page controls - const count = () => { - return filteredData.length; - }; + // Point 2: memoize filtering — only reruns when data or searchTerm changes, + // not on every checkbox tick or button hover. With 10K rows, this eliminates + // a full array scan on every unrelated render. + const filteredData = useMemo( + () => + data.filter( + (s) => + s.ueId?.toLowerCase().includes(searchTerm.toLowerCase()) || + s.plmnID?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [data, searchTerm] + ); - const pager = () => { - if (config.enablePagination) { - return ( - - ); - } else { - return
; - } - }; + // Point 4: clear selection when search changes — avoids deleting subscribers + // that are no longer visible in the filtered result set. + useEffect(() => { + setSelected([]); + }, [searchTerm]); - const onDelete = (id: string, plmn: string) => { - const result = window.confirm("Delete subscriber?"); - if (!result) { - return; - } - axios - .delete("/api/subscriber/" + id + "/" + plmn) - .then((res) => { - props.setRefresh(!props.refresh); - }) - .catch((err) => { - setIsDeleteError(true); - console.error(err.response.data.message); + // Point 5: row click toggles selection by stable ueId+plmnID key, not index. + const handleRowClick = useCallback( + (item: MultipleDeleteSubscriberData) => { + setSelected((prev) => { + const idx = prev.findIndex( + (s) => s.ueId === item.ueId && s.plmnID === item.plmnID + ); + if (idx === -1) return [...prev, item]; + return [...prev.slice(0, idx), ...prev.slice(idx + 1)]; }); - }; - - const onCreate = () => { - navigation("/subscriber/create"); - }; + }, + [] + ); - const handleModify = (subscriber: Subscriber) => { - navigation("/subscriber/" + subscriber.ueId + "/" + subscriber.plmnID); + const handleSelectAllClick = (e: React.ChangeEvent) => { + setSelected( + e.target.checked + ? filteredData.map((r) => ({ ueId: r.ueId!, plmnID: r.plmnID! })) + : [] + ); }; - const handleEdit = (subscriber: Subscriber) => { - navigation("/subscriber/create/" + subscriber.ueId + "/" + subscriber.plmnID); + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + // Point 4: no explicit page reset needed — virtualization always renders + // from the top of filteredData when the reference changes. }; - const filteredData = data.filter((subscriber) => - subscriber.ueId?.toLowerCase().includes(searchTerm.toLowerCase()) || - subscriber.plmnID?.toLowerCase().includes(searchTerm.toLowerCase()) + const onDelete = useCallback( + (id: string, plmn: string) => { + if (!window.confirm("Delete subscriber?")) return; + axios + .delete("/api/subscriber/" + id + "/" + plmn) + .then(() => props.setRefresh(!props.refresh)) + .catch((err) => { + setIsDeleteError(true); + console.error(err.response?.data?.message); + }); + }, + [props] ); - const handleSearch = (event: React.ChangeEvent) => { - setSearchTerm(event.target.value); - setPage(0); // FIX: reset to first page on search so results are always visible - }; - - const handleSelectAllClick = (event: React.ChangeEvent) => { - if (event.target.checked) { - const newSelected = filteredData.map(row => ({ - ueId: row.ueId!, - plmnID: row.plmnID! - })); - setSelected(newSelected); - return; - } - setSelected([]); - }; + const onModify = useCallback( + (s: Subscriber) => + navigation("/subscriber/" + s.ueId + "/" + s.plmnID), + [navigation] + ); - const handleClick = (item: MultipleDeleteSubscriberData) => { - const selectedIndex = selected.findIndex( - s => s.ueId === item.ueId && s.plmnID === item.plmnID - ); - let newSelected: MultipleDeleteSubscriberData[] = []; - - if (selectedIndex === -1) { - newSelected = newSelected.concat(selected, item); - } else if (selectedIndex === 0) { - newSelected = newSelected.concat(selected.slice(1)); - } else if (selectedIndex === selected.length - 1) { - newSelected = newSelected.concat(selected.slice(0, -1)); - } else if (selectedIndex > 0) { - newSelected = newSelected.concat( - selected.slice(0, selectedIndex), - selected.slice(selectedIndex + 1), - ); - } + const onEdit = useCallback( + (s: Subscriber) => + navigation("/subscriber/create/" + s.ueId + "/" + s.plmnID), + [navigation] + ); - setSelected(newSelected); - }; + // Point 3: threshold-based confirmation. + // < THRESHOLD → single confirm with count + 5-item preview + // ≥ THRESHOLD → double confirm: first warning, then final irreversible step + const onDeleteSelected = () => { + const count = selected.length; + const preview = selected + .slice(0, 5) + .map((i) => ` • ${i.ueId} (PLMN: ${i.plmnID})`) + .join("\n"); + const more = count > 5 ? `\n ...and ${count - 5} more` : ""; - const isSelected = (item: MultipleDeleteSubscriberData) => - selected.some(s => s.ueId === item.ueId && s.plmnID === item.plmnID); + const firstMsg = + count >= BULK_DELETE_WARN_THRESHOLD + ? `WARNING: You are about to delete ${count} subscribers.\n\nPreview:\n${preview}${more}\n\nClick OK to continue.` + : `Delete ${count} subscriber(s)?\n\nPreview:\n${preview}${more}`; - const onDeleteSelected = () => { - // FIX: cap confirm message — joining all 10K selected items crashes the browser - const preview = selected.slice(0, 5).map(item => - `PLMN: ${item.plmnID}\tUE ID: ${item.ueId}` - ); - const more = selected.length > 5 ? `\n...and ${selected.length - 5} more` : ""; - const confirmMessage = `Are you sure you want to delete ${selected.length} subscriber(s)?\n\n${preview.join('\n')}${more}`; + if (!window.confirm(firstMsg)) return; - const result = window.confirm(confirmMessage); - if (!result) { + if ( + count >= BULK_DELETE_WARN_THRESHOLD && + !window.confirm( + `Final confirmation: permanently delete ${count} subscribers?\n\nThis cannot be undone.` + ) + ) { return; } - const data = formatMultipleDeleteSubscriberToJson(selected); - - axios.delete("/api/subscriber", { data }) + axios + .delete("/api/subscriber", { + data: formatMultipleDeleteSubscriberToJson(selected), + }) .then(() => { props.setRefresh(!props.refresh); setSelected([]); }) .catch((err) => { setIsDeleteError(true); - console.log(err.response.data.message); + console.log(err.response?.data?.message); }); }; + if (isLoadError) { + return ( + + + Something went wrong + + ); + } + if (data == null || data.length === 0) { return ( <> @@ -207,20 +308,45 @@ function SubscriberList(props: Props) {

- - ) + ); } + // Point 1: react-window FixedSizeList (v2 API: `List`) — only visible rows + // exist in the DOM regardless of dataset size or search result count. + // DOM node count stays bounded to ~(MAX_LIST_HEIGHT / ROW_HEIGHT) ≈ 10 rows. + // + // Column alignment: TableHead uses display:table + table-layout:fixed at + // 100% width. Each VirtualRow applies the same styles so columns align + // without needing a . + const listHeight = Math.min(filteredData.length * ROW_HEIGHT, MAX_LIST_HEIGHT); + + // rowProps is shared across all rows — stable references via useCallback + // ensure react-window does not needlessly remount visible rows. + const rowProps: RowSharedProps = { + rows: filteredData, + selected, + onDelete, + onModify, + onEdit, + onRowClick: handleRowClick, + }; + return ( <>
{selected.length > 0 && ( - )} - +
0 && selected.length < filteredData.length} - checked={filteredData.length > 0 && selected.length === filteredData.length} + indeterminate={ + selected.length > 0 && + selected.length < filteredData.length + } + checked={ + filteredData.length > 0 && + selected.length === filteredData.length + } onChange={handleSelectAllClick} /> @@ -256,56 +393,25 @@ function SubscriberList(props: Props) { Edit - - {/* FIX: slice filteredData to current page — previously rendered ALL rows causing - browser panic with large subscriber counts (e.g. 10,000 DOM nodes at once) */} - {filteredData.slice(page * limit, page * limit + limit).map((row, index) => { - const item = { ueId: row.ueId!, plmnID: row.plmnID! }; - const isItemSelected = isSelected(item); - return ( - handleClick(item)} - role="checkbox" - aria-checked={isItemSelected} - selected={isItemSelected} - > - - - - {row.plmnID} - {row.ueId} - - - - - - - - - - - ); - })} + {/* display:block allows react-window to manage overflow/scroll */} + + + rowComponent={VirtualRow} + rowProps={rowProps} + rowCount={filteredData.length} + rowHeight={ROW_HEIGHT} + style={{ height: listHeight, width: "100%" }} + />
- {pager()} - - @@ -325,10 +431,16 @@ function SubscriberList(props: Props) { function WithDashboard(Component: React.ComponentType) { return function (props: any) { const [refresh, setRefresh] = useState(false); - return ( - setRefresh(!refresh)}> - setRefresh(v)} /> + setRefresh(!refresh)} + > + setRefresh(v)} + /> ); }; From c2a73b9d3343e75fcbea77b82c88e42757a32b84 Mon Sep 17 00:00:00 2001 From: ALIIQBAL786 Date: Wed, 29 Apr 2026 16:03:24 +0500 Subject: [PATCH 04/12] fix: show progress bar instead of blank loading screen during bulk create setLoading(true) was being called in onCreate, causing the component to return
Loading...
early and hide the form and progress bar entirely. Users saw a blank screen with no feedback until all requests completed (or indefinitely for large counts like 50,000). The loading state is only meant for edit mode (fetching an existing subscriber's data). Batch creation progress is already tracked separately via createProgress state, which drives the progress bar and button label. --- frontend/src/pages/SubscriberCreate/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/SubscriberCreate/index.tsx b/frontend/src/pages/SubscriberCreate/index.tsx index 26807da..c65104b 100644 --- a/frontend/src/pages/SubscriberCreate/index.tsx +++ b/frontend/src/pages/SubscriberCreate/index.tsx @@ -141,7 +141,9 @@ function SubscriberCreate() { const total = subscription.userNumber!; setCreateErrors([]); setCreateProgress({ current: 0, total }); - setLoading(true); + // NOTE: do NOT set setLoading(true) here — loading is only for edit mode + // (fetching existing subscriber). Setting it here causes the component to + // return
Loading...
which hides the form and progress bar. // Build the full list of (supi, payload) pairs upfront so the loop body // is free of mutation side effects. @@ -187,7 +189,6 @@ function SubscriberCreate() { setCreateProgress({ current: completed, total }); } - setLoading(false); setCreateProgress(null); if (errors.length > 0) { From 1f8ebfcdad65bc60c61114df0b703f2133f0b168 Mon Sep 17 00:00:00 2001 From: ALIIQBAL786 Date: Wed, 29 Apr 2026 17:13:23 +0500 Subject: [PATCH 05/12] fix: batch bulk delete to prevent silent failure on large selections Replace the single-payload DELETE request with batched requests (BULK_DELETE_BATCH_SIZE=100 items each) so large selections such as 50 K subscribers don't exceed implicit browser/server payload limits that cause the request to be dropped without surfacing an error. Changes: - onDeleteSelected is now async and iterates in batches via await - deleteProgress state drives a LinearProgress bar and disables the button while deletion is in progress (mirrors onCreate pattern) - Console logs added at each batch for browser-side diagnostics - ariaAttributes type widened to Record to match react-window v2's aria prop signature - Unused MUI Table/TableCell/TableHead/TableRow imports removed Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/SubscriberList.tsx | 344 +++++++++++++++----------- 1 file changed, 194 insertions(+), 150 deletions(-) diff --git a/frontend/src/pages/SubscriberList.tsx b/frontend/src/pages/SubscriberList.tsx index ff0de53..477e7d5 100644 --- a/frontend/src/pages/SubscriberList.tsx +++ b/frontend/src/pages/SubscriberList.tsx @@ -16,15 +16,13 @@ import { Alert, Box, Button, + CircularProgress, Grid, + LinearProgress, Snackbar, Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, TextField, + Typography, Checkbox, } from "@mui/material"; import { ReportProblemRounded } from "@mui/icons-material"; @@ -35,9 +33,20 @@ import { // ── constants ──────────────────────────────────────────────────────────────── -const ROW_HEIGHT = 53; -const MAX_LIST_HEIGHT = 530; // ~10 rows visible at once +const ROW_HEIGHT = 52; +const MAX_LIST_HEIGHT = 530; const BULK_DELETE_WARN_THRESHOLD = 100; +const BULK_DELETE_BATCH_SIZE = 100; + +// Column widths shared between header and virtual rows. +// Must stay in sync — change both together. +const COL_CHECKBOX = "52px"; +const COL_PLMN = "18%"; +const COL_UEID = "1fr"; +const COL_DELETE = "110px"; +const COL_VIEW = "90px"; +const COL_EDIT = "90px"; +const GRID_COLS = `${COL_CHECKBOX} ${COL_PLMN} ${COL_UEID} ${COL_DELETE} ${COL_VIEW} ${COL_EDIT}`; // ── types ──────────────────────────────────────────────────────────────────── @@ -46,7 +55,6 @@ interface Props { setRefresh: (v: boolean) => void; } -// Shared data passed to every virtual row via react-window v2 rowProps type RowSharedProps = { rows: Subscriber[]; selected: MultipleDeleteSubscriberData[]; @@ -56,12 +64,11 @@ type RowSharedProps = { onRowClick: (item: MultipleDeleteSubscriberData) => void; }; -// ── virtual row component ──────────────────────────────────────────────────── -// Defined outside SubscriberList so its reference is stable and react-window -// does not remount every visible row on each parent render. +// ── virtual row ────────────────────────────────────────────────────────────── +// Defined outside SubscriberList so its reference is stable across renders. type VirtualRowProps = { - ariaAttributes: Record; + ariaAttributes: Record; index: number; style: CSSProperties; } & RowSharedProps; @@ -84,101 +91,97 @@ function VirtualRow({ plmnID: row.plmnID!, }; - // Point 5: selection keyed by stable ueId+plmnID — not tied to index or - // current scroll position, so selections survive scroll and search changes. + // Selection keyed by stable ueId+plmnID — survives scroll and search changes const isItemSelected = selected.some( (s) => s.ueId === item.ueId && s.plmnID === item.plmnID ); return ( - onRowClick(item)} + sx={{ + display: "grid", + gridTemplateColumns: GRID_COLS, + alignItems: "center", + borderBottom: "1px solid rgba(224,224,224,1)", + backgroundColor: isItemSelected + ? "rgba(25,118,210,0.08)" + : "transparent", + "&:hover": { backgroundColor: isItemSelected ? "rgba(25,118,210,0.12)" : "rgba(0,0,0,0.04)" }, + cursor: "pointer", boxSizing: "border-box", }} - hover - selected={isItemSelected} - onClick={() => onRowClick(item)} - role="checkbox" - aria-checked={isItemSelected} > - - - - {row.plmnID} - {row.ueId} - + + + + + {row.plmnID} + + + {row.ueId} + + - - + + - - + + - - + + ); } -// ── main component ─────────────────────────────────────────────────────────── +// ── component ──────────────────────────────────────────────────────────────── function SubscriberList(props: Props) { const navigation = useNavigate(); const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [isLoadError, setIsLoadError] = useState(false); const [isDeleteError, setIsDeleteError] = useState(false); - - // Point 5: selection stored globally by stable ID, independent of scroll - // position or current visible page — deletions work correctly across the - // full dataset. const [selected, setSelected] = useState([]); + const [deleteProgress, setDeleteProgress] = useState<{ + current: number; + total: number; + } | null>(null); useEffect(() => { + setIsLoading(true); axios .get("/api/subscriber") .then((res) => setData(res.data)) - .catch(() => setIsLoadError(true)); + .catch(() => setIsLoadError(true)) + .finally(() => setIsLoading(false)); }, [props.refresh]); - // Point 2: memoize filtering — only reruns when data or searchTerm changes, - // not on every checkbox tick or button hover. With 10K rows, this eliminates - // a full array scan on every unrelated render. + // useMemo: filter only reruns when data or searchTerm changes, + // not on every checkbox tick const filteredData = useMemo( () => data.filter( @@ -189,25 +192,20 @@ function SubscriberList(props: Props) { [data, searchTerm] ); - // Point 4: clear selection when search changes — avoids deleting subscribers - // that are no longer visible in the filtered result set. + // Clear selection when search changes to avoid acting on hidden items useEffect(() => { setSelected([]); }, [searchTerm]); - // Point 5: row click toggles selection by stable ueId+plmnID key, not index. - const handleRowClick = useCallback( - (item: MultipleDeleteSubscriberData) => { - setSelected((prev) => { - const idx = prev.findIndex( - (s) => s.ueId === item.ueId && s.plmnID === item.plmnID - ); - if (idx === -1) return [...prev, item]; - return [...prev.slice(0, idx), ...prev.slice(idx + 1)]; - }); - }, - [] - ); + const handleRowClick = useCallback((item: MultipleDeleteSubscriberData) => { + setSelected((prev) => { + const idx = prev.findIndex( + (s) => s.ueId === item.ueId && s.plmnID === item.plmnID + ); + if (idx === -1) return [...prev, item]; + return [...prev.slice(0, idx), ...prev.slice(idx + 1)]; + }); + }, []); const handleSelectAllClick = (e: React.ChangeEvent) => { setSelected( @@ -219,8 +217,6 @@ function SubscriberList(props: Props) { const handleSearch = (e: React.ChangeEvent) => { setSearchTerm(e.target.value); - // Point 4: no explicit page reset needed — virtualization always renders - // from the top of filteredData when the reference changes. }; const onDelete = useCallback( @@ -238,8 +234,7 @@ function SubscriberList(props: Props) { ); const onModify = useCallback( - (s: Subscriber) => - navigation("/subscriber/" + s.ueId + "/" + s.plmnID), + (s: Subscriber) => navigation("/subscriber/" + s.ueId + "/" + s.plmnID), [navigation] ); @@ -249,11 +244,13 @@ function SubscriberList(props: Props) { [navigation] ); - // Point 3: threshold-based confirmation. - // < THRESHOLD → single confirm with count + 5-item preview - // ≥ THRESHOLD → double confirm: first warning, then final irreversible step - const onDeleteSelected = () => { + // Batched bulk delete — sends BULK_DELETE_BATCH_SIZE subscribers per request + // so large selections (e.g. 50 K) don't produce a single giant payload that + // the browser or server silently drops. + const onDeleteSelected = async () => { const count = selected.length; + if (count === 0) return; + const preview = selected .slice(0, 5) .map((i) => ` • ${i.ueId} (PLMN: ${i.plmnID})`) @@ -272,22 +269,42 @@ function SubscriberList(props: Props) { !window.confirm( `Final confirmation: permanently delete ${count} subscribers?\n\nThis cannot be undone.` ) - ) { + ) return; + + const items = formatMultipleDeleteSubscriberToJson(selected); + console.log(`[BulkDelete] Starting: ${items.length} subscribers, batch size ${BULK_DELETE_BATCH_SIZE}`); + + setDeleteProgress({ current: 0, total: items.length }); + let completed = 0; + let anyError = false; + + for (let start = 0; start < items.length; start += BULK_DELETE_BATCH_SIZE) { + const batch = items.slice(start, start + BULK_DELETE_BATCH_SIZE); + try { + await axios.delete("/api/subscriber", { data: batch }); + console.log(`[BulkDelete] Batch ${start}–${start + batch.length - 1}: OK`); + } catch (err: any) { + anyError = true; + console.error( + `[BulkDelete] Batch ${start}–${start + batch.length - 1} failed:`, + err.response?.status, + err.response?.data ?? err.message + ); + } + completed += batch.length; + setDeleteProgress({ current: completed, total: items.length }); } - axios - .delete("/api/subscriber", { - data: formatMultipleDeleteSubscriberToJson(selected), - }) - .then(() => { - props.setRefresh(!props.refresh); - setSelected([]); - }) - .catch((err) => { - setIsDeleteError(true); - console.log(err.response?.data?.message); - }); + setDeleteProgress(null); + console.log(`[BulkDelete] Done. anyError=${anyError}`); + + if (anyError) { + setIsDeleteError(true); + } + // Always refresh and clear — partial deletes should still update the list + props.setRefresh(!props.refresh); + setSelected([]); }; if (isLoadError) { @@ -299,14 +316,21 @@ function SubscriberList(props: Props) { ); } + if (isLoading) { + return ( + + + + ); + } + if (data == null || data.length === 0) { return ( <>
No Subscription -
-
+

+ {deleteProgress !== null && ( + + + + {Math.round((deleteProgress.current / deleteProgress.total) * 100)}% complete + + + )} )} - - - - - 0 && - selected.length < filteredData.length - } - checked={ - filteredData.length > 0 && - selected.length === filteredData.length - } - onChange={handleSelectAllClick} - /> - - PLMN - UE ID - Delete - View - Edit - - - {/* display:block allows react-window to manage overflow/scroll */} - - - rowComponent={VirtualRow} - rowProps={rowProps} - rowCount={filteredData.length} - rowHeight={ROW_HEIGHT} - style={{ height: listHeight, width: "100%" }} + + 0 && selected.length < filteredData.length} + checked={filteredData.length > 0 && selected.length === filteredData.length} + onChange={handleSelectAllClick} + size="small" /> - -
+ + PLMN + UE ID + Delete + View + Edit + + + {/* Virtual body — react-window List with explicit pixel height. + Must use pixels not "100%" — react-window v2 uses ResizeObserver + which cannot resolve percentage heights, resulting in 0 rows rendered. */} + + + rowComponent={VirtualRow} + rowProps={rowProps} + rowCount={filteredData.length} + rowHeight={ROW_HEIGHT} + style={{ height: listHeight, width: "100%" }} + /> +