diff --git a/pkg/service/setup/migration_readiness_test.go b/pkg/service/setup/migration_readiness_test.go
index c54e1d41..d4cd21b6 100644
--- a/pkg/service/setup/migration_readiness_test.go
+++ b/pkg/service/setup/migration_readiness_test.go
@@ -331,3 +331,48 @@ func TestMigrationDataReadinessExplainsPresetsDroppedByFull(t *testing.T) {
t.Errorf("action = %q, want it to point at the missing source", notReady.Action)
}
}
+
+// TestMigrationSummaryReportsDataReadiness: the pre-flight panel must show a
+// refusal before the user commits, rather than showing all-green and failing
+// with a 409 at Apply.
+func TestMigrationSummaryReportsDataReadiness(t *testing.T) {
+ t.Run("refusal", func(t *testing.T) {
+ m, _, deviceIP := newMigrationReadinessFixture(t, ``)
+
+ // No persisted snapshot, so the check refuses.
+ summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil)
+ if err != nil {
+ t.Fatalf("GetMigrationSummary: %v", err)
+ }
+
+ if summary.DataReadyError == "" {
+ t.Error("summary hid a refusal that Apply would hit")
+ }
+ })
+
+ t.Run("warning", func(t *testing.T) {
+ m, ds, deviceIP := newMigrationReadinessFixture(t, ``)
+ if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil {
+ t.Fatalf("SavePresets: %v", err)
+ }
+ if err := ds.SaveDeviceInfo(readinessAccount, "SIBLING01", &models.ServiceDeviceInfo{
+ DeviceID: "SIBLING01",
+ AccountID: readinessAccount,
+ Name: "Sibling Speaker",
+ }); err != nil {
+ t.Fatalf("SaveDeviceInfo sibling: %v", err)
+ }
+
+ summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil)
+ if err != nil {
+ t.Fatalf("GetMigrationSummary: %v", err)
+ }
+
+ if summary.DataReadyError != "" {
+ t.Errorf("summary reported a refusal for a shared account: %q", summary.DataReadyError)
+ }
+ if len(summary.DataReadyWarnings) != 1 {
+ t.Errorf("warnings = %v, want one about the device count", summary.DataReadyWarnings)
+ }
+ })
+}
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index 57ac70fb..6f38f736 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -80,6 +80,12 @@ type MigrationSummary struct {
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
PlannedResolv string `json:"planned_resolv,omitempty"`
IsMigrated bool `json:"is_migrated"`
+ // Data readiness, so the pre-flight panel can show a refusal before the
+ // user commits to Apply instead of only surfacing it as a 409 afterwards.
+ // DataReadyError is the reason migration will be refused; empty means it
+ // will proceed. DataReadyWarnings are advisory and do not block.
+ DataReadyError string `json:"data_ready_error,omitempty"`
+ DataReadyWarnings []string `json:"data_ready_warnings,omitempty"`
// Per-axis migration signals — IsMigrated is the OR of these. The UI
// displays them individually so users can see partial states (e.g.
// URLs flipped via telnet but the on-disk XML hasn't caught up, or
@@ -303,6 +309,13 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
SSHSuccess: false,
}
+ // Same read-only check MigrateSpeaker runs, reported rather than enforced.
+ if warnings, err := m.checkMigrationDataReady(deviceIP); err != nil {
+ summary.DataReadyError = err.Error()
+ } else {
+ summary.DataReadyWarnings = warnings
+ }
+
// Run the telnet preflight in parallel with the SSH-based probes below.
// Both transports are queried independently: SSH gives access to
// /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's