Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
"@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",
"react": "^18.2.0",
"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",
Expand Down
188 changes: 154 additions & 34 deletions frontend/src/pages/SubscriberCreate/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<any>) {
return function (props: any) {
return (
Expand All @@ -36,11 +49,22 @@ function SubscriberCreate() {
const navigation = useNavigate();
const [loading, setLoading] = useState(false);
const [profiles, setProfiles] = useState<string[]>([]);
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<string[]>([]);

const { handleSubmit, getValues, reset } = useSubscriptionForm();

useEffect(() => {
axios.get('/api/profile')
axios
.get("/api/profile")
.then((res) => {
setProfiles(res.data);
})
Expand Down Expand Up @@ -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();
Expand All @@ -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 <div>Loading...</div> 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");
}
};

Expand All @@ -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) {
Expand All @@ -164,14 +240,19 @@ 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;
setSelectedProfile(profileName);

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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -242,9 +324,47 @@ function SubscriberCreate() {
<SubscriberFormSessions />

<br />

{/* FIX: progress bar shown during batch creation */}
{isCreating && (
<Grid item xs={12} sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ mb: 0.5 }}>
Creating subscribers: {createProgress.current} / {createProgress.total}
</Typography>
<LinearProgress variant="determinate" value={progressPercent} />
</Grid>
)}

{/* FIX: error summary shown after partial/full failure */}
{createErrors.length > 0 && (
<Grid item xs={12} sx={{ mb: 2 }}>
<Alert severity="error">
<strong>
{createErrors.length} subscriber(s) failed to create:
</strong>
<ul style={{ margin: "4px 0 0 0", paddingLeft: "1.2em" }}>
{createErrors.slice(0, 20).map((e, i) => (
<li key={i}>{e}</li>
))}
{createErrors.length > 20 && (
<li>...and {createErrors.length - 20} more</li>
)}
</ul>
</Alert>
</Grid>
)}

<Grid item xs={12}>
<Button color="primary" variant="contained" type="submit" sx={{ m: 1 }}>
{formSubmitText}
<Button
color="primary"
variant="contained"
type="submit"
disabled={isCreating}
sx={{ m: 1 }}
>
{isCreating
? `Creating… ${progressPercent}%`
: formSubmitText}
</Button>
</Grid>
</form>
Expand Down
Loading
Loading