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:
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"] == ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user