Skip to content

Editor Save → Asterisk Reload UX

As of PR #468 (2026-06-19) editor saves return immediately. The Asterisk regen + reload runs in the background, coalesced per org — a burst of edits collapses into a single reload and the operator is never blocked. See Async background deploy. The synchronous "wait 6–15s with a progress pill" model below is the prior behaviour; its reload-mechanics context (why the reload is slow and serialized) still applies — it just no longer happens on the request path for the converted endpoints.

Async background deploy (PR #468)

Before: every config-mutating save awaited deployOrganizationConfiguration + reloadAsteriskConfiguration before responding — 6–15s per save (see Why the wait exists). Bursty editing (e.g. retiming several queue members) meant one full reload per click, blocking the operator each time and churning Asterisk during live calls.

Now: the converted endpoints write to the DB and return immediately, then hand off to a per-org coalescing scheduler that does the regen+reload in the background.

deployAndReloadInBackground(orgId, orgName, label) — the scheduler

Lives on the configDeploymentService singleton (api/src/services/asterisk/configDeploymentService.js); per-org state in this._deployJobs: Map<orgId, {timer, running, dirty, …}>.

  • Each call marks the org dirty and (re)arms a setTimeout(debounceMs) — a burst collapses to one run debounceMs after the last edit. A maxWaitMs cap (anchored on the first edit) guarantees continuous editing still deploys periodically rather than deferring forever.
  • Defaults debounceMs ≈ 800, maxWaitMs ≈ 5000 (module constants). These only delay when Asterisk reflects the change — the operator's save already returned.
  • _runDeployJob: await deployOrganizationConfigurationskip the reload entirely when changed === false → else await reloadAsteriskConfiguration(), still serialized by the existing _reloadLock mutex. If the org went dirty again mid-run, it re-arms.
  • Per-org isolation: org A's editing never delays org B's deploy; the single Asterisk reload stays globally serialized.
  • flushPendingDeploys() runs any pending coalesced job on graceful shutdown so a deploy isn't lost on restart (best-effort — config lives in the DB regardless).

Skip-unchanged

deployOrganizationConfiguration returns { changed }. writeConfigFile compares the new render to the on-disk file after _stripVolatile() (which ignores the volatile ; Generated at: <ts> header), so re-saving an unchanged form writes nothing and skips the reload. Deploy log shows ⏭️ Config unchanged, skipped write.

Converted endpoints

User update + routing, DID create/update/routing, IVR save/menu-save/publish, and the live queue endpoints (PUT /queues/:id, POST/PATCH/DELETE …/members) call deployAndReloadInBackground. ⚠️ The live queue endpoints are inline in server.jsapi/src/routes/queues.js is dead code (mounted only by the unused app.js); editing it changes nothing (a fix once landed there and had zero effect).

POST /api/v1/users/batch (the editor's bulk "Table Edit" spreadsheet) validates every row first, applies them in one pass, then fires a single background deploy for the whole batch.

Operator-visible

Saves feel instant — a loading→done toast (editor/components/ui/Toast.tsx, loading type + updateToast) confirms the DB write; Asterisk reflects the change ~1s after the last edit. Caveat: the save returns before the reload, so a background deploy failure is logged server-side ([deploy-mgr] …) but the toast already showed success. If a saved change doesn't take effect, grep the api log for [deploy-mgr].

Sibling perf change in #468 — click-to-call shared AMI

Same PR: click-to-call (POST /api/v1/calls/click-to-call) now reuses a shared, long-lived AMI connection via getSharedManager() (api/src/services/asterisk/sharedAmi.js) instead of connect()/disconnect() per click, and the 100 ms post-login delay was removed. Cuts originate latency and fixes the "slow / only missed calls" symptom seen when each click paid a fresh AMI handshake. Unrelated to the config-deploy path, but it's the other half of #468's Asterisk-interaction work.

Why the wait exists

PR #295 (2026-05-23) changed configDeploymentService._doReload() from a fixed 750 ms sleep to active polling until Asterisk's Last reload counter resets — proving the reload actually finished inside Asterisk's reload thread. Required to close the time-domain race that produced the Error 60 wedge.

Result: each save now takes 6-15 seconds total. Each reload step (dialplan / res_pjsip / app_queue) takes 2-5 s — they run serially because Asterisk has ONE reload thread.

Before PR #295: 750 ms regardless of completion → wedges. After PR #295: 6-15 s but always safe.

We chose safe.

The progress pill (PR #298 + #299)

Floating, fixed at viewport bottom, mirrors the audio-preview pill on Departments page so the editor has one transient-overlay pattern. Implemented as:

  • editor/lib/hooks/use-deploy-progress.tsuseDeployProgress() hook. Time-based ramp 0→95% over expectedMs (default 8000), snaps to 100% on finish(), hides 600 ms after.
  • editor/components/ui/deploy-progress-bar.tsx<DeployProgressBar /> component. Reads the hook's isDeploying + progress and renders the pill. Multi-stage label + color transition on completion.

Multi-stage messaging maps to progress %:

% Label Stage
0-15 "Saving changes…" DB write in flight
15-90 "Applying to Asterisk…" The slow reload wait
90-99 "Almost done…" Cleanup phase or longer-than-expected wait
100 "Done" (green) API responded successfully

If the API takes longer than expectedMs (8 s), the bar pins at the 95% soft cap until the response lands. Honest "still working" signal, not a fake completion.

Z-index = z-[100] (PR #299). Strictly higher than shadcn's DialogOverlay/DialogContent which are at z-50. Without this bump the pill rendered behind modal backdrops and looked dimmed.

Wired into

  • Users page: toggleUserStatus() + handleEdit()
  • DIDs page: handleEdit() (covers routing changes, sticky toggle, sticky sub-controls)
  • Departments: handleCreate(), handleEdit(), handleDelete() for queues, Add Member button inside Edit Queue dialog (PR #305)
  • IVR builder: handleSaveMetadata(), handleSaveMenu(), handlePublish(), handleUploadGreeting()

Each call site does:

const dp = useDeployProgress();
async function save() {
  dp.start();
  try {
    await api.update(...);
    dp.finish();
  } catch (e) {
    dp.abort();
    throw e;
  }
}

// In render:
<DeployProgressBar isDeploying={dp.isDeploying} progress={dp.progress} />

Save button also disables + shows "Saving…" label during the deploy — no double-fire from impatient clicks.

Why time-based ramp (not server progress events)

Operationally simpler. The reload duration varies (2-5 s per step) but the order and number of steps is fixed. Time-based ramp gets us 90% of the value of a "real" progress system at near-zero implementation cost. If reload duration gets significantly longer (e.g. orgs with 50+ DIDs), the pill will pin at 95% — still an honest signal, just less informative on the upper end.

If we ever need true streaming progress (e.g. for bulk-import flows that take >30s), SSE on a /config/deploy/stream endpoint is the natural upgrade.

Operator-visible behaviour

  • Single save: pill appears almost immediately (initial value = 2% so the bar is visible), ramps to 95% over 8 s, snaps to 100% with a green checkmark when the API returns, hides after 600 ms.
  • Bursts (e.g. spam-toggling status): each save fires its own start(). The mutex on the server serializes them, so the pill stays visible across the queue — operator sees a continuous "still working" indicator instead of flicker.
  • Error: abort() hides the pill immediately. Toast surfaces the error message.

Troubleshooting

Symptom Likely cause
Pill doesn't appear at all Hard refresh the editor (Cmd+Shift+R). Cached old bundle.
Pill appears but is dimmed under modal z-[100] not deployed yet — pre PR #299. Refresh.
Pill stuck at 95% for >30 s Asterisk reload genuinely hung (Error 60?). Check pm2 logs + pjsip show endpoints.
"Done" never shows but pill hides anyway API returned 5xx, error toast fires, pill aborts immediately. Check the toast.

The "missed button" class — PR #305

The deploy-progress pill was rolled out across the editor in PR #298–#299, but the rollout was applied per save handler — not by static analysis of "every button that calls a deploy-triggering API". A button that should have used deployProgress but didn't can stay clickable while the request is in flight, and an impatient operator can fire the same POST multiple times.

The Fintax incident on 2026-05-23 surfaced exactly this: the Add Member button inside Edit Queue was only disabled when no user was selected; the click handler called queues.addMember(...) which triggers the full deploy chain (DB insert → AMI queue add member → queues.conf rewrite → Asterisk reload, 6–15s on prod). Operator clicked Add four times, first POST succeeded → 201, subsequent POSTs hit queueService.addQueueMember's existing-member check → 4 stacked 409 (Conflict) responses in the console.

PR #305 wrapped the Add Member click with deployProgress.start()/finish()/abort() and added || deployProgress.isDeploying to the disabled condition — mirrors the Save button pattern that already lived in the same dialog at line 1311.

Rule: any button that triggers a deploy-cycle API call MUST use deployProgress

When adding a new editor button whose handler calls one of:

  • users.* mutations
  • dids.* mutations
  • queues.* mutations (including addMember, removeMember, updateMember)
  • ivrs.* mutations
  • anything else that lands in a server-side autoDeploy path

…wire it through deployProgress.start()/finish()/abort() and add || deployProgress.isDeploying to the disabled condition. The DB write is fast (~50 ms); it's the AMI reload + on-disk rewrite that takes 6–15s on prod, and that's the window where the operator can fire duplicate POSTs.

Sister buttons inside the Edit Queue dialog that were NOT updated in PR #305 (penalty up/down arrows, Remove member, ring-time input) are known follow-up candidates. They share the same race surface; fix when they bite (separate PR per fix, not a sweep).

  • Error 60 — the wedge the active-wait was added to prevent
  • [[sticky-agent]] — the feature whose UAT exposed how slow saves became after PR #295