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/SubscriberCreate/index.tsx b/frontend/src/pages/SubscriberCreate/index.tsx index 2a495b9..3e75404 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 = 300; + 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,64 @@ function SubscriberCreate() { return; } - // Iterate subscriber data number. + const total = subscription.userNumber!; + setCreateErrors([]); + setCreateProgress({ current: 0, total }); + // 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. + 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 }); + } + + setCreateProgress(null); + + if (errors.length > 0) { + setCreateErrors(errors); + // Don't navigate — let the user see which subscribers failed. + } else { + navigation("/subscriber"); } }; @@ -142,9 +213,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 +240,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 +251,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 +264,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 +324,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
  • + )} +
+
+
+ )} + - diff --git a/frontend/src/pages/SubscriberList.tsx b/frontend/src/pages/SubscriberList.tsx index 117f247..6867547 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"; @@ -10,297 +16,418 @@ import { Alert, Box, Button, + CircularProgress, Grid, + LinearProgress, Snackbar, Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TablePagination, TextField, + Typography, Checkbox, } from "@mui/material"; import { ReportProblemRounded } from "@mui/icons-material"; -import { MultipleDeleteSubscriberData, formatMultipleDeleteSubscriberToJson } from "../lib/jsonFormating"; +import { + MultipleDeleteSubscriberData, + formatMultipleDeleteSubscriberToJson, +} from "../lib/jsonFormating"; + +const ROW_HEIGHT = 52; +const MAX_LIST_HEIGHT = 530; +const BULK_DELETE_WARN_THRESHOLD = 100; +const BULK_DELETE_BATCH_SIZE = 500; + +// Shared between header and virtual rows — both must use the same template. +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}`; interface Props { refresh: boolean; setRefresh: (v: boolean) => void; } +type RowSharedProps = { + rows: Subscriber[]; + selected: MultipleDeleteSubscriberData[]; + onDelete: (id: string, plmn: string) => void; + onModify: (s: Subscriber) => void; + onEdit: (s: Subscriber) => void; + onRowClick: (item: MultipleDeleteSubscriberData) => void; +}; + +// Defined outside SubscriberList so its reference is stable across renders. +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!, + }; + + 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", + borderColor: "divider", + backgroundColor: isItemSelected ? "action.selected" : "transparent", + "&:hover": { + backgroundColor: isItemSelected ? "action.focus" : "action.hover", + }, + cursor: "pointer", + boxSizing: "border-box", + }} + > + + + + + {row.plmnID} + + + {row.ueId} + + + + + + + + + + + + ); +} + function SubscriberList(props: Props) { const navigation = useNavigate(); const [data, setData] = useState([]); - const [limit, setLimit] = useState(50); - const [page, setPage] = useState(0); + const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [isLoadError, setIsLoadError] = useState(false); const [isDeleteError, setIsDeleteError] = useState(false); const [selected, setSelected] = useState([]); + const [deleteProgress, setDeleteProgress] = useState<{ + current: number; + total: number; + } | null>(null); useEffect(() => { - console.log("get subscribers"); + setIsLoading(true); axios .get("/api/subscriber") - .then((res) => { - setData(res.data); - }) - .catch((e) => { - setIsLoadError(true); - }); - }, [props.refresh, limit, page]); - - if (isLoadError) { - return ( - - - Something went wrong - - ); - } - - const handlePageChange = ( - _event: React.MouseEvent | null, - newPage?: number, - ) => { - if (newPage !== null) { - setPage(newPage!); - } - }; + .then((res) => setData(res.data)) + .catch(() => setIsLoadError(true)) + .finally(() => setIsLoading(false)); + }, [props.refresh]); - const handleLimitChange = (event: React.ChangeEvent) => { - setLimit(Number(event.target.value)); - }; + const filteredData = useMemo( + () => + data.filter( + (s) => + s.ueId?.toLowerCase().includes(searchTerm.toLowerCase()) || + s.plmnID?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [data, searchTerm] + ); - const count = () => { - return 0; - }; + useEffect(() => { + setSelected([]); + }, [searchTerm]); - const pager = () => { - if (config.enablePagination) { - return ( - + const handleRowClick = useCallback((item: MultipleDeleteSubscriberData) => { + setSelected((prev) => { + const idx = prev.findIndex( + (s) => s.ueId === item.ueId && s.plmnID === item.plmnID ); - } else { - return
; - } - }; + if (idx === -1) return [...prev, item]; + return [...prev.slice(0, idx), ...prev.slice(idx + 1)]; + }); + }, []); - 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); - }); + const handleSelectAllClick = (e: React.ChangeEvent) => { + setSelected( + e.target.checked + ? filteredData.map((r) => ({ ueId: r.ueId!, plmnID: r.plmnID! })) + : [] + ); }; - const onCreate = () => { - navigation("/subscriber/create"); + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); }; - const handleModify = (subscriber: Subscriber) => { - navigation("/subscriber/" + subscriber.ueId + "/" + subscriber.plmnID); - }; + 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 handleEdit = (subscriber: Subscriber) => { - navigation("/subscriber/create/" + subscriber.ueId + "/" + subscriber.plmnID); - }; + const onModify = useCallback( + (s: Subscriber) => navigation("/subscriber/" + s.ueId + "/" + s.plmnID), + [navigation] + ); - const filteredData = data.filter((subscriber) => - subscriber.ueId?.toLowerCase().includes(searchTerm.toLowerCase()) || - subscriber.plmnID?.toLowerCase().includes(searchTerm.toLowerCase()) + const onEdit = useCallback( + (s: Subscriber) => + navigation("/subscriber/create/" + s.ueId + "/" + s.plmnID), + [navigation] ); - const handleSearch = (event: React.ChangeEvent) => { - setSearchTerm(event.target.value); - }; + const onDeleteSelected = async () => { + const count = selected.length; + if (count === 0) return; - 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 preview = selected + .slice(0, 5) + .map((i) => ` • ${i.ueId} (PLMN: ${i.plmnID})`) + .join("\n"); + const more = count > 5 ? `\n ...and ${count - 5} more` : ""; - 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 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}`; - setSelected(newSelected); - }; + if (!window.confirm(firstMsg)) return; + + if ( + count >= BULK_DELETE_WARN_THRESHOLD && + !window.confirm( + `Final confirmation: permanently delete ${count} subscribers?\n\nThis cannot be undone.` + ) + ) + return; - const isSelected = (item: MultipleDeleteSubscriberData) => - selected.some(s => s.ueId === item.ueId && s.plmnID === item.plmnID); + const items = formatMultipleDeleteSubscriberToJson(selected); + console.log(`[BulkDelete] Starting: ${items.length} subscribers, batch size ${BULK_DELETE_BATCH_SIZE}`); - const onDeleteSelected = () => { - const selectedItems = selected.map(item => - `PLMN: ${item.plmnID}\tUE ID: ${item.ueId}` - ); + setDeleteProgress({ current: 0, total: items.length }); + let completed = 0; + let anyError = false; - 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; + 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 }); } - const data = formatMultipleDeleteSubscriberToJson(selected); - - axios.delete("/api/subscriber", { data }) - .then(() => { - props.setRefresh(!props.refresh); - setSelected([]); - }) - .catch((err) => { - setIsDeleteError(true); - console.log(err.response.data.message); - }); + setDeleteProgress(null); + + if (anyError) { + setIsDeleteError(true); + } + props.setRefresh(!props.refresh); + setSelected([]); }; + if (isLoadError) { + return ( + + + Something went wrong + + ); + } + + if (isLoading) { + return ( + + + + ); + } + if (data == null || data.length === 0) { return ( <>
No Subscription -
-
+

-
- ) + ); } + const listHeight = Math.min(filteredData.length * ROW_HEIGHT, MAX_LIST_HEIGHT); + + const rowProps: RowSharedProps = { + rows: filteredData, + selected, + onDelete, + onModify, + onEdit, + onRowClick: handleRowClick, + }; + return ( <>
- {selected.length > 0 && ( - + + {(selected.length > 0 || deleteProgress !== null) && ( + + {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 - - - - {filteredData.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} - - - - - - - - - - - ); - })} - -
- {pager()} - - @@ -320,10 +447,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)} + /> ); }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 95a96a4..6b42898 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1183,6 +1183,24 @@ __metadata: languageName: node linkType: hard +"@types/react-window@npm:^1.8.8": + version: 1.8.8 + resolution: "@types/react-window@npm:1.8.8" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f + languageName: node + linkType: hard + +"@types/react@npm:*": + version: 19.2.14 + resolution: "@types/react@npm:19.2.14" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7 + languageName: node + linkType: hard + "@types/react@npm:^18.2.61": version: 18.3.18 resolution: "@types/react@npm:18.3.18" @@ -1876,6 +1894,13 @@ __metadata: languageName: node linkType: hard +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.2": version: 1.0.2 resolution: "data-view-buffer@npm:1.0.2" @@ -3765,6 +3790,7 @@ __metadata: "@types/node": "npm:^20.11.24" "@types/react": "npm:^18.2.61" "@types/react-dom": "npm:^18.2.19" + "@types/react-window": "npm:^1.8.8" "@typescript-eslint/eslint-plugin": "npm:^7.1.0" "@typescript-eslint/parser": "npm:^7.1.0" "@vitejs/plugin-react-swc": "npm:^3.6.0" @@ -3782,6 +3808,7 @@ __metadata: react-dom: "npm:^18.2.0" react-hook-form: "npm:^7.51.5" react-router-dom: "npm:^6.22.2" + react-window: "npm:^2.2.7" typescript: "npm:^5.3.3" vite: "npm:^6.4.2" vitest: "npm:^3.0.7" @@ -4258,6 +4285,16 @@ __metadata: languageName: node linkType: hard +"react-window@npm:^2.2.7": + version: 2.2.7 + resolution: "react-window@npm:2.2.7" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + checksum: 10c0/4eba3bce2083fa53ac674513078fb23d4dc3ad7dfd12ea5733863b583ea1472294df791947a2b58f27bab45138cedf7a63fdc19b0420823bf19749aa10455b81 + languageName: node + linkType: hard + "react@npm:^18.2.0": version: 18.3.1 resolution: "react@npm:18.3.1"