events/capsules: dedupe channel notifications, harden create dialog

- channels dispatcher: drop capsule.{create,pause,resume,destroy} events
  with system actor and no reason metadata. Suppresses the goroutine /
  host-callback follow-up that duplicated every user-initiated action in
  notification channels (Telegram, webhooks). Genuinely system-only
  emitters (TTL auto-pause, host monitor reconciler, host failures) all
  set reason, so they continue to notify.
- CreateCapsuleDialog: wrap submit in try/finally so the creating flag
  always clears, and close the dialog before invoking oncreated to avoid
  the parent receiving the new capsule while the dialog is still open.
- capsules page: guard against double-insertion of the same capsule when
  the SSE event arrives before the dialog's oncreated callback resolves.
This commit is contained in:
2026-05-19 04:27:11 +06:00
parent 42af7c4357
commit 9b34d6a82f
3 changed files with 40 additions and 11 deletions

View File

@ -116,17 +116,20 @@
async function handleCreate() {
creating = true;
createError = null;
const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule;
const result = await creator(createForm);
if (result.ok) {
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
templateQuery = 'minimal';
oncreated?.(result.data);
onclose();
} else {
createError = result.error;
try {
const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule;
const result = await creator(createForm);
if (result.ok) {
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
templateQuery = 'minimal';
onclose();
oncreated?.(result.data);
} else {
createError = result.error;
}
} finally {
creating = false;
}
creating = false;
}
</script>

View File

@ -170,7 +170,9 @@
}
function handleCapsuleCreated(capsule: Capsule) {
capsules = [capsule, ...capsules];
if (!capsules.some((c) => c.id === capsule.id)) {
capsules = [capsule, ...capsules];
}
newCapsuleId = capsule.id;
setTimeout(() => { newCapsuleId = null; }, 1600);
}

View File

@ -101,6 +101,10 @@ func (d *Dispatcher) handleMessage(ctx context.Context, msg redis.XMessage) {
return
}
if isRedundantSystemFollowup(event) {
return
}
teamID, err := id.ParseTeamID(event.TeamID)
if err != nil {
slog.Warn("channels: invalid team ID in event", "team_id", event.TeamID, "error", err)
@ -181,3 +185,23 @@ func (d *Dispatcher) decryptConfig(configJSON []byte) (map[string]string, error)
func isGroupExistsError(err error) bool {
return err != nil && err.Error() == "BUSYGROUP Consumer Group name already exists"
}
// isRedundantSystemFollowup filters out capsule lifecycle events emitted by
// the SandboxService background goroutine / host-agent callback after a
// user-initiated action. The corresponding handler already publishes a
// user-actor event for the same intent; without this filter, every user
// action delivers two notifications.
//
// Genuinely system-only emitters (TTL auto-pause, host_monitor reconciler,
// host-reported failures) always set Metadata["reason"], so they pass.
func isRedundantSystemFollowup(e events.Event) bool {
if e.Actor.Type != events.ActorSystem {
return false
}
switch e.Event {
case events.CapsuleCreate, events.CapsulePause, events.CapsuleResume, events.CapsuleDestroy:
default:
return false
}
return e.Metadata["reason"] == ""
}