From cdc722f2f35ae37f6c5794474ccebda524766331 Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Tue, 6 Oct 2026 17:39:27 -0300 Subject: [PATCH 1/2] feat(frontend): add the database copy to the migration page "Copy the database" starts the copy on the server and follows it while it runs. The run is the server's own process, so the page asks for its events from the last one it saw: after a reload that is the first one, and after a dropped connection the one it had got to. Stopping a copy asks first. When the last copy does not count, the step says why in the page's words, with the command's own words under Details. A database that is not empty, or that holds an earlier copy this instance no longer matches, can only be replaced: the step says so and names the step where a new, empty one is saved. A code the page has no words for reads as a failure. When the step waits again after a copy ended, it says the copy is out of date and has to be made again. A finished copy shows its tables and rows in the step's row and can be made again. The panel is written for the three copy steps. The other two use it next. --- .secrets.baseline | 230 ++++++------ .../API/queries/migration/types.ts | 40 ++- .../API/queries/migration/use-migration.ts | 60 ++++ src/frontend/src/locales/de.json | 20 ++ src/frontend/src/locales/en.json | 20 ++ src/frontend/src/locales/es.json | 20 ++ src/frontend/src/locales/fr.json | 20 ++ src/frontend/src/locales/ja.json | 20 ++ src/frontend/src/locales/ko.json | 20 ++ src/frontend/src/locales/pt.json | 20 ++ src/frontend/src/locales/zh-Hans.json | 20 ++ .../pages/MigrationPage/CheckStep.tsx | 2 +- .../pages/MigrationPage/CopyStep.tsx | 172 +++++++++ .../MigrationPage/__tests__/catalog.test.ts | 51 +++ .../MigrationPage/__tests__/page.test.tsx | 95 ++++- .../MigrationPage/__tests__/steps.test.tsx | 339 +++++++++++++++++- .../pages/MigrationPage/catalog.ts | 44 +++ .../pages/MigrationPage/index.tsx | 31 +- .../core/features/migration-copy.spec.ts | 72 ++++ src/frontend/tests/utils/migration-walk.ts | 38 +- 20 files changed, 1200 insertions(+), 134 deletions(-) create mode 100644 src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx create mode 100644 src/frontend/tests/core/features/migration-copy.spec.ts diff --git a/.secrets.baseline b/.secrets.baseline index bec8c8d362f6..ba4b3b301625 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3023,7 +3023,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "2a24f50d36559883d2bc391e4e99f9a7afd56ef9", "is_verified": false, - "line_number": 2141, + "line_number": 2161, "is_secret": false }, { @@ -3031,7 +3031,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "fd0543de99a00bd6e67f3e522a18ed417089b6b4", "is_verified": false, - "line_number": 2152, + "line_number": 2172, "is_secret": false }, { @@ -3039,7 +3039,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "fa4b98297401af5e6ab7a402330a12715e891325", "is_verified": false, - "line_number": 2304, + "line_number": 2324, "is_secret": false }, { @@ -3047,7 +3047,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "9c46c22e7a8ff423706b990fc02c85e0d324f134", "is_verified": false, - "line_number": 2317, + "line_number": 2337, "is_secret": false }, { @@ -3055,7 +3055,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "91cde4236a3754b4b57733682edfc50571d954c6", "is_verified": false, - "line_number": 2319, + "line_number": 2339, "is_secret": false }, { @@ -3063,7 +3063,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "9625c6b66710b648d27ee284940849a6b5abf3f0", "is_verified": false, - "line_number": 2322, + "line_number": 2342, "is_secret": false }, { @@ -3071,7 +3071,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "370c53187d8e550db85edabdcb86547dab96e300", "is_verified": false, - "line_number": 2327, + "line_number": 2347, "is_secret": false }, { @@ -3079,7 +3079,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cc57a2140542893b4c9396b6a7186b26351c6fe5", "is_verified": false, - "line_number": 2336, + "line_number": 2356, "is_secret": false }, { @@ -3087,7 +3087,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "d922d16c3bbc818b6fab4e8d2662fa361309fa9d", "is_verified": false, - "line_number": 2565, + "line_number": 2585, "is_secret": false }, { @@ -3095,7 +3095,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "8ea601e5cac549eef50d2743f915e4af54135789", "is_verified": false, - "line_number": 2566, + "line_number": 2586, "is_secret": false }, { @@ -3103,7 +3103,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e498ebe977c948f4d1b7eddd6bb77be398ed4d7c", "is_verified": false, - "line_number": 2572, + "line_number": 2592, "is_secret": false }, { @@ -3111,7 +3111,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "02e680473cb1ee7473e2cdb5b0b6c48cd8aae081", "is_verified": false, - "line_number": 2573, + "line_number": 2593, "is_secret": false }, { @@ -3119,7 +3119,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cc50b2aac5d886516a06afb79b4931f4ee66422d", "is_verified": false, - "line_number": 2583, + "line_number": 2603, "is_secret": false }, { @@ -3127,7 +3127,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "07039cae9ab794b348576619cb3ec72eab24b781", "is_verified": false, - "line_number": 2626, + "line_number": 2646, "is_secret": false }, { @@ -3135,7 +3135,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "0070d28a258282ddfc1e1addf78aa9d773735c5e", "is_verified": false, - "line_number": 2627, + "line_number": 2647, "is_secret": false } ], @@ -3329,7 +3329,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "76b33d23eee6aaa96f11abb4cbf1e5abd66aa46d", "is_verified": false, - "line_number": 837, + "line_number": 857, "is_secret": false }, { @@ -3337,7 +3337,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "e9013532fd4966ed4b3aab1ae711b21f180e4c26", "is_verified": false, - "line_number": 838, + "line_number": 858, "is_secret": false }, { @@ -3345,7 +3345,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8f16563f8a1751c141fd1c47148369ca1c380bb1", "is_verified": false, - "line_number": 881, + "line_number": 901, "is_secret": false }, { @@ -3353,7 +3353,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "2c4e1babc2d1448dfcfdd24fb92068644227d6f1", "is_verified": false, - "line_number": 974, + "line_number": 994, "is_secret": false }, { @@ -3361,7 +3361,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "7ce9180d54b399cafbe6515ca2ca4710a6b9554c", "is_verified": false, - "line_number": 980, + "line_number": 1000, "is_secret": false }, { @@ -3369,7 +3369,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "924cf2c6cefbba9e88dc676bd2575b7f21f9661e", "is_verified": false, - "line_number": 987, + "line_number": 1007, "is_secret": false }, { @@ -3377,7 +3377,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "9d6bd29f94e1e565bd5de8f44c875638b26e85b6", "is_verified": false, - "line_number": 988, + "line_number": 1008, "is_secret": false }, { @@ -3385,7 +3385,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8e77e1ff96d9be727f25fc393a48f767935660db", "is_verified": false, - "line_number": 1508, + "line_number": 1528, "is_secret": false }, { @@ -3393,7 +3393,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "4a7c565d4c4430e3bb8fa6c560125d5eb37e7c3d", "is_verified": false, - "line_number": 1761, + "line_number": 1781, "is_secret": false }, { @@ -3401,7 +3401,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "29538b53771fff5bad78e3a48a3a8f88c2f141d2", "is_verified": false, - "line_number": 1846, + "line_number": 1866, "is_secret": false }, { @@ -3409,7 +3409,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8d24e5afd2e9b40d21e4300c893a486aab979795", "is_verified": false, - "line_number": 1847, + "line_number": 1867, "is_secret": false }, { @@ -3417,7 +3417,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "ed05045c1666dd556d9a09f9eb6889f42a81aa5a", "is_verified": false, - "line_number": 1955, + "line_number": 1975, "is_secret": false }, { @@ -3425,7 +3425,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8f616a4d4abc959d505a5b83f1b3fa1a6bce6b2e", "is_verified": false, - "line_number": 1957, + "line_number": 1977, "is_secret": false }, { @@ -3433,7 +3433,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "1bbb8fd38e8fef4280c7218b961ae2ceeb83bbc9", "is_verified": false, - "line_number": 1958, + "line_number": 1978, "is_secret": false }, { @@ -3441,7 +3441,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "0b8eacdde75bb24c6f29adf660f50ca4d9846358", "is_verified": false, - "line_number": 1960, + "line_number": 1980, "is_secret": false }, { @@ -3449,7 +3449,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8cde7462c1ce55676dc45124068d844ad3b86dcf", "is_verified": false, - "line_number": 1981, + "line_number": 2001, "is_secret": false }, { @@ -3457,7 +3457,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "afbf9aed8c22c3faf4d92dccadabe324d49bc5a8", "is_verified": false, - "line_number": 2222, + "line_number": 2242, "is_secret": false }, { @@ -3465,7 +3465,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "651f66f27bbfaed040c836e22515162e20c4fa0d", "is_verified": false, - "line_number": 2236, + "line_number": 2256, "is_secret": false }, { @@ -3473,7 +3473,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "e38ef0e653c39ce1a4dde13da87333c43ced6cab", "is_verified": false, - "line_number": 2321, + "line_number": 2341, "is_secret": false }, { @@ -3481,7 +3481,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "044b852f30b636aa923b18a61a35c79abc87556f", "is_verified": false, - "line_number": 2629, + "line_number": 2649, "is_secret": false }, { @@ -3489,7 +3489,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "e40123b4e7879a56fc22171a9ae637d418288daa", "is_verified": false, - "line_number": 2630, + "line_number": 2650, "is_secret": false } ], @@ -3747,7 +3747,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "5c324bbf8f0a15d640a2ca93b4bb0183d106edd8", "is_verified": false, - "line_number": 2141, + "line_number": 2161, "is_secret": false }, { @@ -3755,7 +3755,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "49d8b438094e36554d1d2ec73dbc4adbdca1a891", "is_verified": false, - "line_number": 2152, + "line_number": 2172, "is_secret": false }, { @@ -3763,7 +3763,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "bdd17febdd742a928650952ec63b0ab0d7eeddac", "is_verified": false, - "line_number": 2317, + "line_number": 2337, "is_secret": false }, { @@ -3771,7 +3771,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "15e4f8d5f1d2759b1e83f9082a8ce8e194eb5600", "is_verified": false, - "line_number": 2319, + "line_number": 2339, "is_secret": false }, { @@ -3779,7 +3779,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "c8700cb4716f270e7c37191104366e7b1cadc9de", "is_verified": false, - "line_number": 2322, + "line_number": 2342, "is_secret": false }, { @@ -3787,7 +3787,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "60b0610326aa371e079775047f4e66c77aabb0a2", "is_verified": false, - "line_number": 2327, + "line_number": 2347, "is_secret": false }, { @@ -3795,7 +3795,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "0147f29ee8d8343d8d70f94b71b0053b268461e3", "is_verified": false, - "line_number": 2336, + "line_number": 2356, "is_secret": false }, { @@ -3803,7 +3803,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "3c89182cf4fc330080cfb2352105827a904d5691", "is_verified": false, - "line_number": 2565, + "line_number": 2585, "is_secret": false }, { @@ -3811,7 +3811,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "998c11b06dfd04593f289507da2b499cf4bca6e8", "is_verified": false, - "line_number": 2566, + "line_number": 2586, "is_secret": false }, { @@ -3819,7 +3819,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "ccaa726ee57e4ccdab0a28886273feee26d647ec", "is_verified": false, - "line_number": 2572, + "line_number": 2592, "is_secret": false }, { @@ -3827,7 +3827,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "2e329e7c25daa340c17b719f5371248f691de123", "is_verified": false, - "line_number": 2573, + "line_number": 2593, "is_secret": false }, { @@ -3835,7 +3835,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "6e09157ed6a7516c860d26d0747fb229badf190a", "is_verified": false, - "line_number": 2583, + "line_number": 2603, "is_secret": false }, { @@ -3843,7 +3843,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "3d04be8b9cef8a2743fc91a6e34c0272a1b3a194", "is_verified": false, - "line_number": 2626, + "line_number": 2646, "is_secret": false }, { @@ -3851,7 +3851,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "a0e898048bbec1b000a6a80fb85068f85bae1cc2", "is_verified": false, - "line_number": 2627, + "line_number": 2647, "is_secret": false } ], @@ -4061,7 +4061,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "b1df0ac48f734b370fc58c591ef0772266f88bb4", "is_verified": false, - "line_number": 2141, + "line_number": 2161, "is_secret": false }, { @@ -4069,7 +4069,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "541b6268f8abceae80c498212280ee7136eba255", "is_verified": false, - "line_number": 2152, + "line_number": 2172, "is_secret": false }, { @@ -4077,7 +4077,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "ff959bbe348ba1b8176852cd2e3d6ba8dbf4062d", "is_verified": false, - "line_number": 2317, + "line_number": 2337, "is_secret": false }, { @@ -4085,7 +4085,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "73f311b6a3b5bb2b45eccad848db62d85ae37b58", "is_verified": false, - "line_number": 2327, + "line_number": 2347, "is_secret": false }, { @@ -4093,7 +4093,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "7294ab5b6d4cf3ea2deab49cecb0a7baacd16447", "is_verified": false, - "line_number": 2572, + "line_number": 2592, "is_secret": false }, { @@ -4101,7 +4101,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "8dfdd0969287ef24a795d71e17f53f148d37d766", "is_verified": false, - "line_number": 2583, + "line_number": 2603, "is_secret": false }, { @@ -4109,7 +4109,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "64153b3b2beefe87ab8c005b00b731785d31b4ea", "is_verified": false, - "line_number": 2626, + "line_number": 2646, "is_secret": false }, { @@ -4117,7 +4117,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "6ef3c010ee446fee1446618f12c9a38262ac8e42", "is_verified": false, - "line_number": 2627, + "line_number": 2647, "is_secret": false } ], @@ -4295,7 +4295,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b7cd1a2885e8998336c89a87d4df110a6b66e2f2", "is_verified": false, - "line_number": 2152, + "line_number": 2172, "is_secret": false }, { @@ -4303,7 +4303,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "5e8bcadfaab440649c70e43a015503e9109a7bfa", "is_verified": false, - "line_number": 2304, + "line_number": 2324, "is_secret": false }, { @@ -4311,7 +4311,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "44ed46c9ee10a3a7308c6d1a5df4cb082c57b9e0", "is_verified": false, - "line_number": 2317, + "line_number": 2337, "is_secret": false }, { @@ -4319,7 +4319,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "20e6aa34a3dd984dd602c294ee85714b53a0a872", "is_verified": false, - "line_number": 2319, + "line_number": 2339, "is_secret": false }, { @@ -4327,7 +4327,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b5426a913c9da60eff621ee15e0b195bf6aaa8da", "is_verified": false, - "line_number": 2322, + "line_number": 2342, "is_secret": false }, { @@ -4335,7 +4335,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "415c03a82cd5f390ad3e28dd82c88e36fb540548", "is_verified": false, - "line_number": 2327, + "line_number": 2347, "is_secret": false }, { @@ -4343,7 +4343,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "9e7ba6c71dc246eee615e88e31861a563e33b42d", "is_verified": false, - "line_number": 2336, + "line_number": 2356, "is_secret": false }, { @@ -4351,7 +4351,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "18a887dd97555ab726e7f4d4d999dedd236763eb", "is_verified": false, - "line_number": 2565, + "line_number": 2585, "is_secret": false }, { @@ -4359,7 +4359,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "e4777a4b7b9c6fd1911762e35405c0b6b80ae735", "is_verified": false, - "line_number": 2566, + "line_number": 2586, "is_secret": false }, { @@ -4367,7 +4367,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "8847d054d32b57b7a5633fed58444d7237209e24", "is_verified": false, - "line_number": 2572, + "line_number": 2592, "is_secret": false }, { @@ -4375,7 +4375,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "369cb27892790b429dacabb4ee12cdfd63d6c250", "is_verified": false, - "line_number": 2573, + "line_number": 2593, "is_secret": false }, { @@ -4383,7 +4383,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b9743a370a7e12046cbb762dda96137a0327fc28", "is_verified": false, - "line_number": 2583, + "line_number": 2603, "is_secret": false } ], @@ -4497,7 +4497,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "c9d6e376c30dbaaf2142fae361283ea0667358fa", "is_verified": false, - "line_number": 836, + "line_number": 856, "is_secret": false }, { @@ -4505,7 +4505,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "39ff30e476f5d150e3e57d3b05f958e11ece81c1", "is_verified": false, - "line_number": 837, + "line_number": 857, "is_secret": false }, { @@ -4513,7 +4513,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "78eb85e1be91197a49c929c9fd4a5a72835150d7", "is_verified": false, - "line_number": 838, + "line_number": 858, "is_secret": false }, { @@ -4521,7 +4521,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "165c88c7ec06f8d48f2362de61d591d5d41b72fe", "is_verified": false, - "line_number": 881, + "line_number": 901, "is_secret": false }, { @@ -4529,7 +4529,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "60eb4bb0f2a3ace6de3da60369f70602bd112fe9", "is_verified": false, - "line_number": 987, + "line_number": 1007, "is_secret": false }, { @@ -4537,7 +4537,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "9c5b4c8b9cebe87bd33fe4c9413cf75059da92c8", "is_verified": false, - "line_number": 988, + "line_number": 1008, "is_secret": false }, { @@ -4545,7 +4545,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "450789a827b2a299f08abc20469945926bbfe969", "is_verified": false, - "line_number": 1351, + "line_number": 1371, "is_secret": false }, { @@ -4553,7 +4553,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "6f5f8b14277a6d648f25cf462f85bcdf7c6dadda", "is_verified": false, - "line_number": 1508, + "line_number": 1528, "is_secret": false }, { @@ -4561,7 +4561,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "a3ec89f4eadb02663999f07df46f6757a4815e37", "is_verified": false, - "line_number": 1846, + "line_number": 1866, "is_secret": false }, { @@ -4569,7 +4569,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "5736978315ed308f81b8b2051af651f79cbbe7f4", "is_verified": false, - "line_number": 1847, + "line_number": 1867, "is_secret": false }, { @@ -4577,7 +4577,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "ed05045c1666dd556d9a09f9eb6889f42a81aa5a", "is_verified": false, - "line_number": 1955, + "line_number": 1975, "is_secret": false }, { @@ -4585,7 +4585,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "8188d6827255f313a42dc29ebebfb29e04eb3531", "is_verified": false, - "line_number": 1957, + "line_number": 1977, "is_secret": false }, { @@ -4593,7 +4593,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "1bbb8fd38e8fef4280c7218b961ae2ceeb83bbc9", "is_verified": false, - "line_number": 1958, + "line_number": 1978, "is_secret": false }, { @@ -4601,7 +4601,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "f4016d49eebd49624cb4a4918e634bc4d38ccf8b", "is_verified": false, - "line_number": 1960, + "line_number": 1980, "is_secret": false }, { @@ -4609,7 +4609,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "88b0c4cc0c8ec5b42da4e12265df55824b30ee60", "is_verified": false, - "line_number": 1981, + "line_number": 2001, "is_secret": false }, { @@ -4617,7 +4617,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "72c9efe2f4665310c55a9146c4113a188d313d56", "is_verified": false, - "line_number": 2222, + "line_number": 2242, "is_secret": false }, { @@ -4625,7 +4625,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "da3ba116b30c3b2e0251bb1f213951a8b5503a28", "is_verified": false, - "line_number": 2236, + "line_number": 2256, "is_secret": false }, { @@ -4633,7 +4633,7 @@ "filename": "src/frontend/src/locales/ko.json", "hashed_secret": "7ee6d673f5c394c8e0a36c15d26e1ab809da5c92", "is_verified": false, - "line_number": 2321, + "line_number": 2341, "is_secret": false } ], @@ -4899,7 +4899,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "e5de519e1707a85ae531c311d440ee66c9d92013", "is_verified": false, - "line_number": 2141, + "line_number": 2161, "is_secret": false }, { @@ -4907,7 +4907,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "5e210e2b1f3abec2110bc9be8e64d7f330fa1077", "is_verified": false, - "line_number": 2152, + "line_number": 2172, "is_secret": false }, { @@ -4915,7 +4915,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "0f0b397ea447a3fa2ad71bf2275fef3c57c19ced", "is_verified": false, - "line_number": 2304, + "line_number": 2324, "is_secret": false }, { @@ -4923,7 +4923,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "9a36a9b7c6bfc059b4f02372fff29d59a7d28956", "is_verified": false, - "line_number": 2317, + "line_number": 2337, "is_secret": false }, { @@ -4931,7 +4931,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "d041f1238574c9fda80f329a69df103dd0362201", "is_verified": false, - "line_number": 2319, + "line_number": 2339, "is_secret": false }, { @@ -4939,7 +4939,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "cb9c85cb748ddd0400375630f5ec0206759ddb89", "is_verified": false, - "line_number": 2322, + "line_number": 2342, "is_secret": false }, { @@ -4947,7 +4947,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "0f426df5e9101c108190dccfd037a953d0d402f2", "is_verified": false, - "line_number": 2327, + "line_number": 2347, "is_secret": false }, { @@ -4955,7 +4955,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "01817544892d96dedca2ad884fa493fea87ba133", "is_verified": false, - "line_number": 2336, + "line_number": 2356, "is_secret": false }, { @@ -4963,7 +4963,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "187a0dc92fd69a86bd0e675941e7f1898e3b55e1", "is_verified": false, - "line_number": 2565, + "line_number": 2585, "is_secret": false }, { @@ -4971,7 +4971,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "685a15b5a75fafecd74ad06561f50711e97d7275", "is_verified": false, - "line_number": 2566, + "line_number": 2586, "is_secret": false }, { @@ -4979,7 +4979,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "47a5deeb29d9b4f9d88464d050560a7f7b294ddd", "is_verified": false, - "line_number": 2572, + "line_number": 2592, "is_secret": false }, { @@ -4987,7 +4987,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "32c3af10cab241e0b425cebc42831cbaa236f1c6", "is_verified": false, - "line_number": 2573, + "line_number": 2593, "is_secret": false }, { @@ -4995,7 +4995,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "4cb21cbeac2b9488b0fde6c6291caf37245c54c2", "is_verified": false, - "line_number": 2583, + "line_number": 2603, "is_secret": false }, { @@ -5003,7 +5003,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "eb352ce61357f4686f0bd96845cda822bc6e8f78", "is_verified": false, - "line_number": 2626, + "line_number": 2646, "is_secret": false }, { @@ -5011,7 +5011,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "4fadcd6a2e084d2c9a962254f1944a5b11bd272d", "is_verified": false, - "line_number": 2627, + "line_number": 2647, "is_secret": false } ], @@ -5173,7 +5173,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "74a3dd23222f2bc1888171506f585281009f79a4", "is_verified": false, - "line_number": 2152, + "line_number": 2172, "is_secret": false }, { @@ -5181,7 +5181,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "c10e8dbe79654ae8ed496c4c50f5e188b7924a4e", "is_verified": false, - "line_number": 2304, + "line_number": 2324, "is_secret": false }, { @@ -5189,7 +5189,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7223cfc50b72fe9b091b46275f561526aba9e705", "is_verified": false, - "line_number": 2317, + "line_number": 2337, "is_secret": false }, { @@ -5197,7 +5197,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "4aa9a3231780af9a37413cb7034ecdf72d43cfec", "is_verified": false, - "line_number": 2319, + "line_number": 2339, "is_secret": false }, { @@ -5205,7 +5205,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "68af7627d36cc6b923f5928a198165857f1ba5f1", "is_verified": false, - "line_number": 2322, + "line_number": 2342, "is_secret": false }, { @@ -5213,7 +5213,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7acd3659ef48c980ce4aef1d0f2f218ff25efc89", "is_verified": false, - "line_number": 2327, + "line_number": 2347, "is_secret": false }, { @@ -5221,7 +5221,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7443c6e0ef4c013461140fb1aa2a7a60b40a0b89", "is_verified": false, - "line_number": 2336, + "line_number": 2356, "is_secret": false }, { @@ -5229,7 +5229,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "3c274d81d83ceba5bfaaaf2ac4f6b0aedcf431a0", "is_verified": false, - "line_number": 2565, + "line_number": 2585, "is_secret": false }, { @@ -5237,7 +5237,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "583ddc85a613ceef2e54f9d2251113f3acef73a3", "is_verified": false, - "line_number": 2566, + "line_number": 2586, "is_secret": false }, { @@ -5245,7 +5245,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8494b7db80f8bfedeef8cc5697bed5339cd1a4ee", "is_verified": false, - "line_number": 2572, + "line_number": 2592, "is_secret": false }, { @@ -5253,7 +5253,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "d45af2467cb991008a273eb0c4590fa9a96fc497", "is_verified": false, - "line_number": 2573, + "line_number": 2593, "is_secret": false }, { @@ -5261,7 +5261,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8da5daf1ef780fe5e37ae02bee48cbdb023c7101", "is_verified": false, - "line_number": 2583, + "line_number": 2603, "is_secret": false } ], diff --git a/src/frontend/src/controllers/API/queries/migration/types.ts b/src/frontend/src/controllers/API/queries/migration/types.ts index c835825b3e65..a8cb7ce47b43 100644 --- a/src/frontend/src/controllers/API/queries/migration/types.ts +++ b/src/frontend/src/controllers/API/queries/migration/types.ts @@ -26,6 +26,42 @@ export interface MigrationStep { error: string | null; } +/** The steps that copy this instance's data. Each one runs a command of its own on the server. */ +export type CopyStepId = + | "copy_database" + | "copy_knowledge_bases" + | "copy_files"; + +/** The latest run of a copy step, as the record keeps it. */ +export interface MigrationCopyRun { + run_id: string; + status: "running" | "done" | "failed" | "cancelled" | "interrupted"; + /** A test run says what a copy would do and never completes its step. */ + dry_run: boolean; + started_by: string; + started_at: string; + finished_at: string | null; + /** The command's last word on a run that ended. */ + report: { + ok: boolean; + tables_copied?: number; + rows_copied?: number; + problems?: { code: string; message: string }[]; + } | null; + /** Set when the run did not end done: the command's own code and message, or `crashed`, `cancelled` or `interrupted`. */ + error: { code: string; message?: string } | null; +} + +/** One line of a run's event stream. The server numbers each, so a page can ask for what came after one. */ +export interface CopyEvent { + event: "progress" | "item" | "report" | "error" | "decision_needed" | "end"; + seq: number; + /** On `progress`: `checking`, `preparing_target` or `copying`. A line with none is copying. */ + phase?: string; + done?: number; + total?: number | null; +} + export type MigrationStepId = | "check_source" | "connect_target" @@ -58,7 +94,9 @@ export interface MigrationState { }; record: { target: { version?: string; set_by?: string; set_at?: string }; - steps: { check_source?: MigrationStep }; + steps: { check_source?: MigrationStep } & Partial< + Record + >; accepted_findings: AcceptedFinding[]; /** Where the new instance keeps its data. Each part is there only when this instance needs it, and none holds a secret. */ destinations?: { diff --git a/src/frontend/src/controllers/API/queries/migration/use-migration.ts b/src/frontend/src/controllers/API/queries/migration/use-migration.ts index d987338eb2e8..7fe2d952ade2 100644 --- a/src/frontend/src/controllers/API/queries/migration/use-migration.ts +++ b/src/frontend/src/controllers/API/queries/migration/use-migration.ts @@ -3,6 +3,8 @@ import type { AxiosError, AxiosResponse } from "axios"; import { api, performStreamingRequest } from "../../api"; import { getURL } from "../../helpers/constants"; import type { + CopyEvent, + CopyStepId, DestinationsRequest, DestinationsSaved, MigrationCheckEvent, @@ -160,3 +162,61 @@ export const useConfirmBackupMutation = () => location, }), ); + +/** Starts a copy. It is a process of its own on the server, so it keeps going when the page is gone. */ +export const useStartCopyMutation = (step: CopyStepId) => { + const client = useQueryClient(); + return useMutation, void>({ + mutationFn: () => + api.post(getURL("MIGRATION", { path: `steps/${step}/runs` }), { + dry_run: false, + }), + // The record now says the run is on, or what changed for the server to refuse it. + onSettled: () => client.invalidateQueries({ queryKey: migrationKeys.all }), + }); +}; + +/** Stops a run. The page learns that it ended from the run's own events. */ +export const useStopCopyMutation = (step: CopyStepId) => + useMutation({ + mutationFn: (runId) => + api.delete(getURL("MIGRATION", { path: `steps/${step}/runs/${runId}` })), + }); + +/** + * Hands over each event of a run that comes after the one numbered `after`, as it happens, down to its end. + * Resolves with the refusal when the server has no such run, or status 0 if the connection fails. + * Aborting the controller stops nothing on the server. + */ +export async function followCopy({ + step, + runId, + after, + controller, + onEvent, +}: { + step: CopyStepId; + runId: string; + after: number; + controller: AbortController; + onEvent: (event: CopyEvent) => void; +}): Promise<{ status: number } | undefined> { + let refused: { status: number } | undefined; + await performStreamingRequest({ + method: "GET", + url: `${getURL("MIGRATION", { path: `steps/${step}/runs/${runId}/events` })}?after=${after}`, + buildController: controller, + onError: (status) => { + refused = { status }; + }, + onData: async (data) => { + // A refusal answers with one JSON error body in place of the events. + if (!refused) onEvent(data as CopyEvent); + return !refused; + }, + onNetworkError: () => { + if (!controller.signal.aborted) refused ??= { status: 0 }; + }, + }); + return refused; +} diff --git a/src/frontend/src/locales/de.json b/src/frontend/src/locales/de.json index 0422ddb25174..1a29b00b19f5 100644 --- a/src/frontend/src/locales/de.json +++ b/src/frontend/src/locales/de.json @@ -2133,6 +2133,26 @@ "settings.migration.backup.location.help": "Erforderlich. Geben Sie an, wo die Sicherung liegt, damit sie wiedergefunden wird, etwa ein Bucket mit Ordner oder der Name eines anderen Rechners. Langflow prüft die Angabe nicht.", "settings.migration.backup.action": "Ich habe alles gesichert", "settings.migration.backup.done": "Gesichert am {{time}} nach {{location}}.", + "settings.migration.copy.starting": "Wird gestartet…", + "settings.migration.copy.checking": "Es wird geprüft, was zu kopieren ist…", + "settings.migration.copy.preparing": "Die neue Datenbank wird vorbereitet…", + "settings.migration.copy.again": "Erneut kopieren", + "settings.migration.copy.stale": "Diese Kopie ist veraltet und zählt nicht mehr. Kopieren Sie erneut.", + "settings.migration.copy.stopTitle": "Kopieren stoppen?", + "settings.migration.copyDb.body": "Kopiert alle Tabellen in einem Durchgang. Wenn etwas fehlschlägt, bleibt nichts erhalten und Sie können erneut kopieren.", + "settings.migration.copyDb.progress": "Zeilen: {{done}} von {{total}}", + "settings.migration.copyDb.stopBody": "Von einer gestoppten Kopie bleibt nichts erhalten.", + "settings.migration.copyDb.done": "Tabellen: {{tables}}. Zeilen: {{rows}}.", + "settings.migration.error.interrupted": "Vor dem Abschluss gestoppt. Führen Sie den Schritt erneut aus.", + "settings.migration.error.crashed": "Der Schritt wurde mit einem Fehler beendet. Führen Sie ihn erneut aus. Wenn das wieder passiert, laden Sie das Migrationsprotokoll herunter.", + "settings.migration.error.runningElsewhere": "Ein anderer Schritt läuft gerade. Warten Sie, bis er abgeschlossen ist.", + "settings.migration.error.destinationChanged": "Das Ziel wurde nach dieser Kopie geändert. Kopieren Sie erneut.", + "settings.migration.error.enterAgain": "Langflow hat die Passwörter und Schlüssel der neuen Instanz nicht mehr. Geben Sie sie unter „{{step}}“ erneut ein.", + "settings.migration.error.targetUnreachable": "Die Datenbank der neuen Instanz ist nicht erreichbar. Prüfen Sie „{{step}}“ und versuchen Sie es erneut.", + "settings.migration.error.dbTargetNotEmpty": "Die Datenbank der neuen Instanz ist nicht leer. Verwenden Sie eine neue, leere Datenbank, ändern Sie sie unter „{{step}}“ und kopieren Sie erneut.", + "settings.migration.error.dbCountMismatch": "Die Datenbank der neuen Instanz enthält eine frühere Kopie, die nicht mehr zu dieser Instanz passt. Erneutes Kopieren in diese Datenbank behebt das nicht. Speichern Sie unter „{{step}}“ eine neue, leere Datenbank und kopieren Sie erneut.", + "settings.migration.error.orphansNoRule": "Einige Zeilen verweisen auf gelöschte Elemente und lassen sich nicht sicher auslassen. Laden Sie das Migrationsprotokoll herunter und wenden Sie sich an den Support.", + "settings.migration.error.valueRejected": "Einige Werte lassen sich nicht in PostgreSQL speichern. Laden Sie das Migrationsprotokoll herunter und wenden Sie sich an den Support.", "settings.nav.modelProviders": "Modellanbieter", "settings.nav.shortcuts": "Tastenkürzel", "settings.nav.store": "Langflow-Shop", diff --git a/src/frontend/src/locales/en.json b/src/frontend/src/locales/en.json index f2eb07767ab5..4576b228445e 100644 --- a/src/frontend/src/locales/en.json +++ b/src/frontend/src/locales/en.json @@ -760,6 +760,26 @@ "settings.migration.backup.location.help": "Required. Say where you put the backup so it can be found again, for example a bucket and folder, or the name of another machine. Langflow doesn't check it.", "settings.migration.backup.action": "I've backed up everything", "settings.migration.backup.done": "Backed up {{time}} to {{location}}.", + "settings.migration.copy.starting": "Starting…", + "settings.migration.copy.checking": "Checking what there is to copy…", + "settings.migration.copy.preparing": "Preparing the new database…", + "settings.migration.copy.again": "Copy again", + "settings.migration.copy.stale": "This copy is out of date, so it no longer counts. Copy again.", + "settings.migration.copy.stopTitle": "Stop copying?", + "settings.migration.copyDb.body": "Copies every table in one go. If anything fails, nothing is kept and you can copy again.", + "settings.migration.copyDb.progress": "Rows: {{done}} of {{total}}", + "settings.migration.copyDb.stopBody": "Nothing from a stopped copy is kept.", + "settings.migration.copyDb.done": "Tables: {{tables}}. Rows: {{rows}}.", + "settings.migration.error.interrupted": "Stopped before it finished. Run it again.", + "settings.migration.error.crashed": "The step stopped with an error. Run it again. If it happens again, download the migration record.", + "settings.migration.error.runningElsewhere": "Another step is running. Wait for it to finish.", + "settings.migration.error.destinationChanged": "The destination changed after this copy was made. Copy again.", + "settings.migration.error.enterAgain": "Langflow no longer holds the passwords and keys of the new instance. Enter them again in '{{step}}'.", + "settings.migration.error.targetUnreachable": "Can't reach the new instance's database. Check '{{step}}', then try again.", + "settings.migration.error.dbTargetNotEmpty": "The new instance's database isn't empty. Use a new, empty database, change it in '{{step}}', then copy again.", + "settings.migration.error.dbCountMismatch": "The new instance's database holds an earlier copy that no longer matches this instance. Copying into it again can't fix that. Save a new, empty database in '{{step}}', then copy again.", + "settings.migration.error.orphansNoRule": "Some rows point at deleted items and can't be left out safely. Download the migration record and contact support.", + "settings.migration.error.valueRejected": "Some values can't be stored in PostgreSQL. Download the migration record and contact support.", "messages.title": "Messages", "messages.loadOlder": "Load older messages", "messages.loadError": "Could not load messages.", diff --git a/src/frontend/src/locales/es.json b/src/frontend/src/locales/es.json index 3a515bd5ac54..b3de5363545c 100644 --- a/src/frontend/src/locales/es.json +++ b/src/frontend/src/locales/es.json @@ -2133,6 +2133,26 @@ "settings.migration.backup.location.help": "Obligatorio. Indica dónde dejaste la copia de seguridad para poder encontrarla después, por ejemplo, un bucket y una carpeta, o el nombre de otra máquina. Langflow no lo comprueba.", "settings.migration.backup.action": "He hecho copia de seguridad de todo", "settings.migration.backup.done": "Copia de seguridad hecha el {{time}} en {{location}}.", + "settings.migration.copy.starting": "Iniciando…", + "settings.migration.copy.checking": "Comprobando qué hay que copiar…", + "settings.migration.copy.preparing": "Preparando la nueva base de datos…", + "settings.migration.copy.again": "Copiar de nuevo", + "settings.migration.copy.stale": "Esta copia está desactualizada y ya no cuenta. Copia de nuevo.", + "settings.migration.copy.stopTitle": "¿Detener la copia?", + "settings.migration.copyDb.body": "Copia todas las tablas de una vez. Si algo falla, no se conserva nada y puedes volver a copiar.", + "settings.migration.copyDb.progress": "Filas: {{done}} de {{total}}", + "settings.migration.copyDb.stopBody": "No se conserva nada de una copia detenida.", + "settings.migration.copyDb.done": "Tablas: {{tables}}. Filas: {{rows}}.", + "settings.migration.error.interrupted": "Se detuvo antes de terminar. Vuelve a ejecutarlo.", + "settings.migration.error.crashed": "El paso se detuvo con un error. Vuelve a ejecutarlo. Si ocurre de nuevo, descarga el registro de migración.", + "settings.migration.error.runningElsewhere": "Hay otro paso en ejecución. Espera a que termine.", + "settings.migration.error.destinationChanged": "El destino cambió después de hacer esta copia. Copia de nuevo.", + "settings.migration.error.enterAgain": "Langflow ya no tiene las contraseñas ni las claves de la nueva instancia. Vuelve a introducirlas en '{{step}}'.", + "settings.migration.error.targetUnreachable": "No se puede acceder a la base de datos de la nueva instancia. Revisa '{{step}}' y vuelve a intentarlo.", + "settings.migration.error.dbTargetNotEmpty": "La base de datos de la nueva instancia no está vacía. Usa una base de datos nueva y vacía, cámbiala en '{{step}}' y copia de nuevo.", + "settings.migration.error.dbCountMismatch": "La base de datos de la nueva instancia contiene una copia anterior que ya no coincide con esta instancia. Copiar de nuevo en ella no lo arregla. Guarda una base de datos nueva y vacía en '{{step}}' y copia de nuevo.", + "settings.migration.error.orphansNoRule": "Algunas filas apuntan a elementos eliminados y no se pueden omitir con seguridad. Descarga el registro de migración y contacta con soporte.", + "settings.migration.error.valueRejected": "Algunos valores no se pueden guardar en PostgreSQL. Descarga el registro de migración y contacta con soporte.", "settings.nav.modelProviders": "Proveedores de modelos", "settings.nav.shortcuts": "Atajos", "settings.nav.store": "Tienda Langflow", diff --git a/src/frontend/src/locales/fr.json b/src/frontend/src/locales/fr.json index a474a43738e9..60dfc3aafa1d 100644 --- a/src/frontend/src/locales/fr.json +++ b/src/frontend/src/locales/fr.json @@ -2133,6 +2133,26 @@ "settings.migration.backup.location.help": "Obligatoire. Indiquez où se trouve la sauvegarde pour pouvoir la retrouver, par exemple un bucket et un dossier, ou le nom d'une autre machine. Langflow ne le vérifie pas.", "settings.migration.backup.action": "J'ai tout sauvegardé", "settings.migration.backup.done": "Sauvegardé le {{time}} vers {{location}}.", + "settings.migration.copy.starting": "Démarrage…", + "settings.migration.copy.checking": "Vérification de ce qu'il y a à copier…", + "settings.migration.copy.preparing": "Préparation de la nouvelle base de données…", + "settings.migration.copy.again": "Copier à nouveau", + "settings.migration.copy.stale": "Cette copie n'est plus à jour et ne compte plus. Copiez à nouveau.", + "settings.migration.copy.stopTitle": "Arrêter la copie ?", + "settings.migration.copyDb.body": "Copie toutes les tables en une seule fois. En cas d'échec, rien n'est conservé et vous pouvez recommencer.", + "settings.migration.copyDb.progress": "Lignes : {{done}} sur {{total}}", + "settings.migration.copyDb.stopBody": "Rien n'est conservé d'une copie arrêtée.", + "settings.migration.copyDb.done": "Tables : {{tables}}. Lignes : {{rows}}.", + "settings.migration.error.interrupted": "Arrêté avant la fin. Relancez l'étape.", + "settings.migration.error.crashed": "L'étape s'est arrêtée sur une erreur. Relancez-la. Si cela se reproduit, téléchargez le journal de migration.", + "settings.migration.error.runningElsewhere": "Une autre étape est en cours. Attendez qu'elle se termine.", + "settings.migration.error.destinationChanged": "La destination a changé après cette copie. Copiez à nouveau.", + "settings.migration.error.enterAgain": "Langflow n'a plus les mots de passe ni les clés de la nouvelle instance. Saisissez-les à nouveau dans « {{step}} ».", + "settings.migration.error.targetUnreachable": "Impossible de joindre la base de données de la nouvelle instance. Vérifiez « {{step}} », puis réessayez.", + "settings.migration.error.dbTargetNotEmpty": "La base de données de la nouvelle instance n'est pas vide. Utilisez une base de données neuve et vide, modifiez-la dans « {{step}} », puis copiez à nouveau.", + "settings.migration.error.dbCountMismatch": "La base de données de la nouvelle instance contient une copie antérieure qui ne correspond plus à cette instance. Copier de nouveau dans cette base ne corrige rien. Enregistrez une base de données neuve et vide dans « {{step}} », puis copiez à nouveau.", + "settings.migration.error.orphansNoRule": "Certaines lignes renvoient à des éléments supprimés et ne peuvent pas être écartées sans risque. Téléchargez le journal de migration et contactez l'assistance.", + "settings.migration.error.valueRejected": "Certaines valeurs ne peuvent pas être stockées dans PostgreSQL. Téléchargez le journal de migration et contactez l'assistance.", "settings.nav.modelProviders": "Fournisseurs de modèles", "settings.nav.shortcuts": "Raccourcis", "settings.nav.store": "Boutique Langflow", diff --git a/src/frontend/src/locales/ja.json b/src/frontend/src/locales/ja.json index 9cc47e8430a3..0483d8b979c0 100644 --- a/src/frontend/src/locales/ja.json +++ b/src/frontend/src/locales/ja.json @@ -2133,6 +2133,26 @@ "settings.migration.backup.location.help": "必須です。後で見つけられるように、バックアップの保存場所を入力します。たとえば、バケットとフォルダー、または別のマシンの名前です。Langflow は入力内容を確認しません。", "settings.migration.backup.action": "すべてバックアップしました", "settings.migration.backup.done": "{{time}} に {{location}} へバックアップしました。", + "settings.migration.copy.starting": "開始しています…", + "settings.migration.copy.checking": "コピーする内容を確認しています…", + "settings.migration.copy.preparing": "新しいデータベースを準備しています…", + "settings.migration.copy.again": "もう一度コピー", + "settings.migration.copy.stale": "このコピーは古くなったため、無効です。もう一度コピーしてください。", + "settings.migration.copy.stopTitle": "コピーを停止しますか?", + "settings.migration.copyDb.body": "すべてのテーブルを一度にコピーします。途中で失敗した場合は何も残らず、もう一度コピーできます。", + "settings.migration.copyDb.progress": "行: {{done}} / {{total}}", + "settings.migration.copyDb.stopBody": "停止したコピーの内容は何も残りません。", + "settings.migration.copyDb.done": "テーブル: {{tables}}。行: {{rows}}。", + "settings.migration.error.interrupted": "完了する前に停止しました。もう一度実行してください。", + "settings.migration.error.crashed": "このステップはエラーで停止しました。もう一度実行してください。再び起きた場合は、移行記録をダウンロードしてください。", + "settings.migration.error.runningElsewhere": "別のステップが実行中です。完了するまでお待ちください。", + "settings.migration.error.destinationChanged": "このコピーの後に移行先が変更されました。もう一度コピーしてください。", + "settings.migration.error.enterAgain": "新しいインスタンスのパスワードとキーは Langflow に残っていません。「{{step}}」でもう一度入力してください。", + "settings.migration.error.targetUnreachable": "新しいインスタンスのデータベースに接続できません。「{{step}}」を確認して、もう一度お試しください。", + "settings.migration.error.dbTargetNotEmpty": "新しいインスタンスのデータベースは空ではありません。新しい空のデータベースを用意し、「{{step}}」で変更してから、もう一度コピーしてください。", + "settings.migration.error.dbCountMismatch": "新しいインスタンスのデータベースには、このインスタンスと一致しなくなった以前のコピーが残っています。同じデータベースにコピーし直しても解決しません。「{{step}}」で新しい空のデータベースを保存してから、もう一度コピーしてください。", + "settings.migration.error.orphansNoRule": "一部の行は削除された項目を参照しており、安全に除外できません。移行記録をダウンロードして、サポートにお問い合わせください。", + "settings.migration.error.valueRejected": "一部の値は PostgreSQL に保存できません。移行記録をダウンロードして、サポートにお問い合わせください。", "settings.nav.modelProviders": "モデルプロバイダー", "settings.nav.shortcuts": "ショートカット", "settings.nav.store": "Langflowストア", diff --git a/src/frontend/src/locales/ko.json b/src/frontend/src/locales/ko.json index 2e8f77789866..217962222a5d 100644 --- a/src/frontend/src/locales/ko.json +++ b/src/frontend/src/locales/ko.json @@ -760,6 +760,26 @@ "settings.migration.backup.location.help": "필수 항목입니다. 나중에 다시 찾을 수 있도록 백업을 둔 곳을 입력하세요(예: 버킷과 폴더, 또는 다른 컴퓨터의 이름). Langflow는 이 내용을 확인하지 않습니다.", "settings.migration.backup.action": "모두 백업했습니다", "settings.migration.backup.done": "{{time}}에 {{location}}에 백업했습니다.", + "settings.migration.copy.starting": "시작하는 중…", + "settings.migration.copy.checking": "복사할 내용을 확인하는 중…", + "settings.migration.copy.preparing": "새 데이터베이스를 준비하는 중…", + "settings.migration.copy.again": "다시 복사", + "settings.migration.copy.stale": "이 복사는 오래되어 더 이상 유효하지 않습니다. 다시 복사하세요.", + "settings.migration.copy.stopTitle": "복사를 중지할까요?", + "settings.migration.copyDb.body": "모든 테이블을 한 번에 복사합니다. 중간에 실패하면 아무것도 남지 않으며 다시 복사할 수 있습니다.", + "settings.migration.copyDb.progress": "행: {{done}} / {{total}}", + "settings.migration.copyDb.stopBody": "중지한 복사에서는 아무것도 남지 않습니다.", + "settings.migration.copyDb.done": "테이블: {{tables}}. 행: {{rows}}.", + "settings.migration.error.interrupted": "끝나기 전에 중지되었습니다. 다시 실행하세요.", + "settings.migration.error.crashed": "이 단계가 오류로 중지되었습니다. 다시 실행하세요. 또 발생하면 마이그레이션 기록을 다운로드하세요.", + "settings.migration.error.runningElsewhere": "다른 단계가 실행 중입니다. 끝날 때까지 기다리세요.", + "settings.migration.error.destinationChanged": "이 복사 이후에 대상이 바뀌었습니다. 다시 복사하세요.", + "settings.migration.error.enterAgain": "Langflow에 새 인스턴스의 비밀번호와 키가 더 이상 없습니다. '{{step}}' 단계에서 다시 입력하세요.", + "settings.migration.error.targetUnreachable": "새 인스턴스의 데이터베이스에 연결할 수 없습니다. '{{step}}' 단계를 확인한 뒤 다시 시도하세요.", + "settings.migration.error.dbTargetNotEmpty": "새 인스턴스의 데이터베이스가 비어 있지 않습니다. 비어 있는 새 데이터베이스를 쓰고, '{{step}}' 단계에서 바꾼 뒤 다시 복사하세요.", + "settings.migration.error.dbCountMismatch": "새 인스턴스의 데이터베이스에 이 인스턴스와 더 이상 맞지 않는 이전 복사본이 있습니다. 같은 데이터베이스에 다시 복사해도 해결되지 않습니다. '{{step}}' 단계에서 비어 있는 새 데이터베이스를 저장한 뒤 다시 복사하세요.", + "settings.migration.error.orphansNoRule": "일부 행이 삭제된 항목을 가리키고 있어 안전하게 제외할 수 없습니다. 마이그레이션 기록을 다운로드하고 지원팀에 문의하세요.", + "settings.migration.error.valueRejected": "일부 값은 PostgreSQL에 저장할 수 없습니다. 마이그레이션 기록을 다운로드하고 지원팀에 문의하세요.", "messages.title": "메시지", "messages.loadOlder": "이전 메시지 불러오기", "messages.loadError": "메시지를 불러오지 못했습니다.", diff --git a/src/frontend/src/locales/pt.json b/src/frontend/src/locales/pt.json index aadd82385795..9461375fc390 100644 --- a/src/frontend/src/locales/pt.json +++ b/src/frontend/src/locales/pt.json @@ -2133,6 +2133,26 @@ "settings.migration.backup.location.help": "Obrigatório. Informe onde o backup ficou para que possa ser encontrado depois, por exemplo, um bucket e uma pasta, ou o nome de outra máquina. O Langflow não verifica isso.", "settings.migration.backup.action": "Fiz o backup de tudo", "settings.migration.backup.done": "Backup feito em {{time}} em {{location}}.", + "settings.migration.copy.starting": "Iniciando…", + "settings.migration.copy.checking": "Verificando o que há para copiar…", + "settings.migration.copy.preparing": "Preparando o novo banco de dados…", + "settings.migration.copy.again": "Copiar novamente", + "settings.migration.copy.stale": "Esta cópia está desatualizada e não conta mais. Copie novamente.", + "settings.migration.copy.stopTitle": "Parar a cópia?", + "settings.migration.copyDb.body": "Copia todas as tabelas de uma vez. Se algo falhar, nada é mantido e você pode copiar novamente.", + "settings.migration.copyDb.progress": "Linhas: {{done}} de {{total}}", + "settings.migration.copyDb.stopBody": "Nada de uma cópia interrompida é mantido.", + "settings.migration.copyDb.done": "Tabelas: {{tables}}. Linhas: {{rows}}.", + "settings.migration.error.interrupted": "Parou antes de terminar. Execute novamente.", + "settings.migration.error.crashed": "A etapa parou com um erro. Execute novamente. Se acontecer de novo, baixe o registro da migração.", + "settings.migration.error.runningElsewhere": "Outra etapa está em execução. Aguarde até que termine.", + "settings.migration.error.destinationChanged": "O destino mudou depois desta cópia. Copie novamente.", + "settings.migration.error.enterAgain": "O Langflow não tem mais as senhas e as chaves da nova instância. Informe-as novamente em '{{step}}'.", + "settings.migration.error.targetUnreachable": "Não foi possível acessar o banco de dados da nova instância. Verifique '{{step}}' e tente novamente.", + "settings.migration.error.dbTargetNotEmpty": "O banco de dados da nova instância não está vazio. Use um banco de dados novo e vazio, altere-o em '{{step}}' e copie novamente.", + "settings.migration.error.dbCountMismatch": "O banco de dados da nova instância contém uma cópia anterior que não corresponde mais a esta instância. Copiar de novo para ele não resolve. Salve um banco de dados novo e vazio em '{{step}}' e copie novamente.", + "settings.migration.error.orphansNoRule": "Algumas linhas apontam para itens excluídos e não podem ser deixadas de fora com segurança. Baixe o registro da migração e fale com o suporte.", + "settings.migration.error.valueRejected": "Alguns valores não podem ser armazenados no PostgreSQL. Baixe o registro da migração e fale com o suporte.", "settings.nav.modelProviders": "Provedores de modelos", "settings.nav.shortcuts": "Atalhos", "settings.nav.store": "Loja Langflow", diff --git a/src/frontend/src/locales/zh-Hans.json b/src/frontend/src/locales/zh-Hans.json index 77d482a8ac28..04f2a7609225 100644 --- a/src/frontend/src/locales/zh-Hans.json +++ b/src/frontend/src/locales/zh-Hans.json @@ -2133,6 +2133,26 @@ "settings.migration.backup.location.help": "必填。请填写备份的存放位置,以便之后能找到,例如某个存储桶和文件夹,或另一台机器的名称。Langflow 不会检查此内容。", "settings.migration.backup.action": "我已备份所有内容", "settings.migration.backup.done": "已于 {{time}} 备份到 {{location}}。", + "settings.migration.copy.starting": "正在启动…", + "settings.migration.copy.checking": "正在检查需要复制的内容…", + "settings.migration.copy.preparing": "正在准备新数据库…", + "settings.migration.copy.again": "再次复制", + "settings.migration.copy.stale": "此次复制已过期,不再有效。请再次复制。", + "settings.migration.copy.stopTitle": "停止复制?", + "settings.migration.copyDb.body": "一次复制所有表。如果中途失败,不会保留任何内容,您可以再次复制。", + "settings.migration.copyDb.progress": "行:{{done}} / {{total}}", + "settings.migration.copyDb.stopBody": "已停止的复制不会保留任何内容。", + "settings.migration.copyDb.done": "表:{{tables}}。行:{{rows}}。", + "settings.migration.error.interrupted": "未完成就已停止。请再次运行。", + "settings.migration.error.crashed": "此步骤因错误而停止。请再次运行。如果再次发生,请下载迁移记录。", + "settings.migration.error.runningElsewhere": "另一个步骤正在运行。请等待其完成。", + "settings.migration.error.destinationChanged": "此次复制之后目标已更改。请再次复制。", + "settings.migration.error.enterAgain": "Langflow 已不再保存新实例的密码和密钥。请在“{{step}}”中重新输入。", + "settings.migration.error.targetUnreachable": "无法连接到新实例的数据库。请检查“{{step}}”,然后重试。", + "settings.migration.error.dbTargetNotEmpty": "新实例的数据库不为空。请使用全新的空数据库,在“{{step}}”中更改后再次复制。", + "settings.migration.error.dbCountMismatch": "新实例的数据库中有一份较早的副本,已与此实例不一致。再次复制到该数据库无法解决。请在“{{step}}”中保存全新的空数据库,然后再次复制。", + "settings.migration.error.orphansNoRule": "部分行指向已删除的项目,无法安全地排除。请下载迁移记录并联系技术支持。", + "settings.migration.error.valueRejected": "部分值无法存储到 PostgreSQL 中。请下载迁移记录并联系技术支持。", "settings.nav.modelProviders": "模型提供商", "settings.nav.shortcuts": "快捷方式", "settings.nav.store": "Langflow 商店", diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CheckStep.tsx b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CheckStep.tsx index 91dfc4e78924..da51e1827670 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CheckStep.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CheckStep.tsx @@ -424,7 +424,7 @@ function CheckRow({ } /** The backend's own words for a check, or a command's output, in English. */ -function Details({ +export function Details({ text, title, open, diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx new file mode 100644 index 000000000000..44e936f38919 --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx @@ -0,0 +1,172 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + type CopyEvent, + type CopyStepId, + followCopy, + type MigrationCopyRun, + type MigrationState, + type MigrationStepState, + migrationKeys, + useStartCopyMutation, + useStopCopyMutation, +} from "@/controllers/API/queries/migration"; +import ConfirmationModal from "@/modals/confirmationModal"; +import { Details } from "./CheckStep"; +import { COPIES, COPY_CODES, copyProgress, STEP_SLUGS } from "./catalog"; + +// How long the page waits before it asks again for a run whose connection dropped. +const RETRY_MS = 2000; + +/** The body of a copy step: start the copy, follow it while it runs, stop it, and say how the last one went. */ +export function CopyStep({ + migration, + state, + step, +}: { + migration: MigrationState; + state: MigrationStepState; + step: CopyStepId; +}) { + const { t, i18n } = useTranslation(); + const run = migration.record.steps[step]; + const progress = useProgress(step, run); + const start = useStartCopyMutation(step); + const stop = useStopCopyMutation(step); + const [confirming, setConfirming] = useState(false); + const slug = COPIES[step]?.slug; + // The step a refusal sends the admin back to. + const destinations = t( + `settings.migration.step.${STEP_SLUGS.connect_target}.title`, + ); + const line = (code = "") => + t(`settings.migration.${COPY_CODES[code] ?? "error.crashed"}`, { + step: destinations, + }); + const refusal = start.error?.response?.data?.detail?.code ?? ""; + + if (run?.status === "running") { + const [key, counts] = progress + ? copyProgress(step, progress, i18n.language) + : ["copy.starting", {}]; + return ( +
+

+ {t(`settings.migration.${key}`, counts)} +

+ {stop.isError && ( +

+ {t("settings.migration.failed")} +

+ )} + + setConfirming(false)} + onCancel={() => setConfirming(false)} + title={t("settings.migration.copy.stopTitle")} + cancelText={t("modal.cancelButton")} + confirmationText={t("settings.migration.check.stop")} + onConfirm={() => { + setConfirming(false); + stop.mutate(run.run_id); + }} + size="x-small" + > + + {t(`settings.migration.${slug}.stopBody`)} + + +
+ ); + } + + // The command's own words for a run that did not count. + const said = + run?.error?.message ?? + run?.report?.problems?.map((problem) => problem.message).join("\n"); + // A copy that ended in a step that waits again no longer counts, so it is history. + const stale = state.state === "current" && Boolean(run); + return ( +
+

+ {t(`settings.migration.${slug}.body`)} +

+ {stale &&

{t("settings.migration.copy.stale")}

} + {state.state === "blocked" && ( +
+

{line(state.reason)}

+ {said &&
} +
+ )} + {start.isError && ( +

+ {refusal in COPY_CODES + ? line(refusal) + : t("settings.migration.failed")} +

+ )} + +
+ ); +} + +/** + * The last progress line of a run that is on. A run belongs to no page, so this one follows it from + * the last line it saw: after a reload that is the first, and after a dropped connection the one it had got to. + */ +function useProgress(step: CopyStepId, run?: MigrationCopyRun) { + const client = useQueryClient(); + const [progress, setProgress] = useState(); + const live = run?.status === "running" ? run.run_id : undefined; + useEffect(() => { + if (!live) return; + setProgress(undefined); + const controller = new AbortController(); + (async () => { + let seq = 0; + let over = false; + while (!over && !controller.signal.aborted) { + const refused = await followCopy({ + step, + runId: live, + after: seq, + controller, + onEvent: (event) => { + seq = event.seq; + if (event.event === "progress") setProgress(event); + over ||= event.event === "end"; + }, + }); + // A run the server no longer has was started again from somewhere else. + over ||= refused?.status === 404; + if (!over) + await new Promise((resolve) => setTimeout(resolve, RETRY_MS)); + } + // The server writes down how a run ended the next time the record is read. + if (!controller.signal.aborted) + client.invalidateQueries({ queryKey: migrationKeys.all }); + })(); + return () => controller.abort(); + }, [step, live, client]); + return progress; +} diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/catalog.test.ts b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/catalog.test.ts index 841410f3d6f2..e89ec4f52344 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/catalog.test.ts +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/catalog.test.ts @@ -6,6 +6,8 @@ import en from "@/locales/en.json"; import { acceptanceOf, CHECKS, + COPY_CODES, + copyProgress, destinationsRequest, groupChecks, JOB_STATES, @@ -231,3 +233,52 @@ describe("pgDumpCommand", () => { ); }); }); + +describe("COPY_CODES", () => { + // The page builds these keys from the server's code, so the i18n check can't see one that is missing. + it("has a line for every reason the page words when a copy does not count", () => { + expect(Object.keys(COPY_CODES).sort()).toEqual([ + "cancelled", + "count_mismatch", + "crashed", + "destination_changed", + "interrupted", + "orphans_no_rule", + "run_active", + "secrets_missing", + "target_not_empty", + "target_unreachable", + "value_rejected", + ]); + expect( + Object.values(COPY_CODES).filter( + (key) => !(`settings.migration.${key}` in en), + ), + ).toEqual([]); + }); +}); + +describe("copyProgress", () => { + const progress = (phase?: string, done = 0, total: number | null = null) => + ({ event: "progress", seq: 1, phase, done, total }) as const; + + it("says what a run is doing, and counts in the reader's language once it copies", () => { + expect(copyProgress("copy_database", progress("checking"), "en")).toEqual([ + "copy.checking", + {}, + ]); + expect( + copyProgress("copy_database", progress("preparing_target"), "en"), + ).toEqual(["copy.preparing", {}]); + expect( + copyProgress("copy_database", progress("copying", 1200, 57000), "en"), + ).toEqual(["copyDb.progress", { done: "1,200", total: "57,000" }]); + expect( + copyProgress("copy_database", progress("copying", 1200, 57000), "de"), + ).toEqual(["copyDb.progress", { done: "1.200", total: "57.000" }]); + // A line with no phase is one of the copy itself. + expect( + copyProgress("copy_database", progress(undefined, 3, 9), "en"), + ).toEqual(["copyDb.progress", { done: "3", total: "9" }]); + }); +}); diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/page.test.tsx b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/page.test.tsx index 8fedfaa506d4..30341ce72bcc 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/page.test.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/page.test.tsx @@ -10,6 +10,7 @@ import userEvent from "@testing-library/user-event"; import { AxiosError } from "axios"; import { api } from "@/controllers/API/api"; import { + type MigrationCopyRun, type MigrationState, type MigrationStepState, migrationKeys, @@ -59,7 +60,34 @@ function open(migration: MigrationState) { const row = (id: MigrationStepState["id"]) => within(screen.getByTestId(`migration-step-${id}`)); -afterEach(() => jest.restoreAllMocks()); +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); + +// Everything before the copies is done. +const copied: Parameters[0] = { + check_source: ["done"], + connect_target: ["done"], + secret_key: ["done"], + pause: ["done"], + backup: ["done"], +}; +const run = (status: MigrationCopyRun["status"]): MigrationCopyRun => ({ + run_id: "run-1", + status, + dry_run: false, + started_by: "alice", + started_at: "2026-10-06T12:05:00Z", + finished_at: status === "running" ? null : "2026-10-06T12:06:00Z", + report: + status === "done" + ? { ok: true, tables_copied: 1059, rows_copied: 12345 } + : null, + error: null, +}); describe("the steps after the check", () => { it("offers a step only when the server can do it", () => { @@ -86,11 +114,14 @@ describe("the steps after the check", () => { secret_key: ["done"], pause: ["done"], backup: ["done"], - copy_database: ["current"], + copy_database: ["skipped"], + copy_knowledge_bases: ["skipped"], + copy_files: ["skipped"], + start_target: ["current"], }), ); - expect(row("copy_database").getByText("Coming soon")).toBeInTheDocument(); + expect(row("start_target").getByText("Coming soon")).toBeInTheDocument(); }); it("opens the step the admin is on, and holds no form for one they haven't reached", () => { @@ -102,11 +133,19 @@ describe("the steps after the check", () => { ).toBeVisible(); unmount(); - open(state({ check_source: ["current"], connect_target: ["locked"] })); + open( + state({ + check_source: ["current"], + connect_target: ["locked"], + copy_database: ["locked"], + }), + ); // Not hidden either: a locked step has nothing to fill in or to press. - expect( - row("connect_target").queryByRole("button", { hidden: true }), - ).not.toBeInTheDocument(); + for (const id of ["connect_target", "copy_database"] as const) { + expect( + row(id).queryByRole("button", { hidden: true }), + ).not.toBeInTheDocument(); + } }); it("sums up where the data goes once it is saved", () => { @@ -374,4 +413,46 @@ describe("the steps after the check", () => { ).toBeInTheDocument(); expect(row("backup").queryByRole("button")).not.toBeInTheDocument(); }); + + it("sums up the database copy once it is done, and keeps it one click away", async () => { + open( + state( + { ...copied, copy_database: ["done"] }, + { steps: { copy_database: run("done") } }, + ), + ); + + expect( + row("copy_database").getByText("Tables: 1,059. Rows: 12,345."), + ).toBeInTheDocument(); + const again = row("copy_database").getByRole("button", { + name: "Copy again", + hidden: true, + }); + expect(again).not.toBeVisible(); + await userEvent.click( + row("copy_database").getByRole("button", { name: "Copy the database" }), + ); + expect(again).toBeVisible(); + }); + + it("shows a copy as working while its run is on", () => { + global.fetch = jest.fn(() => new Promise(() => {})); + const marker = () => + row("copy_database").getByRole("heading").parentElement + ?.previousElementSibling; + + const { unmount } = open(state({ ...copied, copy_database: ["current"] })); + expect(marker()).toHaveClass("bg-primary"); + unmount(); + + open( + state( + { ...copied, copy_database: ["current"] }, + { steps: { copy_database: run("running") } }, + ), + ); + expect(marker()).not.toHaveClass("bg-primary"); + expect(row("copy_database").getByText("Starting…")).toBeVisible(); + }); }); diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx index 8081e1520133..4578e9e14d97 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx @@ -1,14 +1,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AxiosError } from "axios"; import type { ReactElement } from "react"; import { api } from "@/controllers/API/api"; import type { + MigrationCopyRun, MigrationState, MigrationStepState, } from "@/controllers/API/queries/migration"; import { BackupStep } from "../BackupStep"; +import { CopyStep } from "../CopyStep"; import { DestinationsStep } from "../DestinationsStep"; import { PausedBanner, PauseStep, Recovery, Waiting } from "../PauseStep"; import { SecretKeyStep } from "../SecretKeyStep"; @@ -30,6 +32,7 @@ const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; jest.restoreAllMocks(); + jest.useRealTimers(); }); const migration = ( @@ -1023,3 +1026,337 @@ describe("Back up this instance", () => { ); }); }); + +describe("Copy the database", () => { + const copying = (run?: Partial) => + migration( + {}, + { + steps: { + copy_database: run && { + run_id: "run-1", + status: "done", + dry_run: false, + started_by: "alice", + started_at: "2026-10-06T12:05:00Z", + finished_at: "2026-10-06T12:06:00Z", + report: null, + error: null, + ...run, + }, + }, + }, + ); + const panel = ( + run: Partial | undefined, + ...where: [MigrationStepState["state"], string?] + ) => ( + + ); + // A stream as the server sends one: each line an event. It ends, or the connection drops. + const stream = (lines: object[], dropped = false) => { + const chunks = [ + Buffer.from(lines.map((line) => `${JSON.stringify(line)}\n\n`).join("")), + ]; + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + const value = chunks.shift(); + if (!value && dropped) throw new TypeError("network error"); + return { done: !value, value }; + }, + }), + }, + }; + }; + // A stream that stays open and says nothing. + const silent = () => jest.fn(() => new Promise(() => {})); + + it("offers the copy and sends it as a real run", async () => { + const post = jest.spyOn(api, "post").mockRejectedValue(unreachable()); + show(panel(undefined, "current")); + + expect( + screen.getByText(/^Copies every table in one go\./), + ).toBeInTheDocument(); + // The database copy has no way to try it first. + expect(screen.getAllByRole("button")).toHaveLength(1); + expect(screen.queryByText(/out of date/)).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "Copy the database" }), + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Something went wrong. Try again.", + ); + expect(post).toHaveBeenCalledWith( + expect.stringContaining("steps/copy_database/runs"), + { dry_run: false }, + ); + }); + + it.each([ + [ + "secrets_missing", + "Langflow no longer holds the passwords and keys of the new instance. Enter them again in 'Where your data goes'.", + ], + ["run_active", "Another step is running. Wait for it to finish."], + ])("says why the server would not start it: %s", async (code, line) => { + jest.spyOn(api, "post").mockRejectedValue(refused(409, { code })); + show(panel(undefined, "current")); + + await userEvent.click( + screen.getByRole("button", { name: "Copy the database" }), + ); + + expect(await screen.findByRole("alert")).toHaveTextContent(line); + }); + + it("follows a run from the last line it saw, and reads the record again when it ends", async () => { + jest.useFakeTimers(); + const fetched = jest + .fn() + .mockResolvedValueOnce( + stream( + [ + { + event: "progress", + phase: "checking", + done: 0, + total: null, + seq: 1, + }, + { + event: "progress", + phase: "copying", + done: 10, + total: 57, + seq: 2, + }, + ], + true, + ), + ) + .mockResolvedValueOnce( + stream([{ event: "end", status: "done", exit_code: 0, seq: 3 }]), + ); + global.fetch = fetched; + const client = new QueryClient(); + const reread = jest.spyOn(client, "invalidateQueries"); + render( + + {panel({ status: "running", finished_at: null }, "current")} + , + ); + + expect(await screen.findByText("Rows: 10 of 57")).toBeInTheDocument(); + expect(fetched.mock.calls[0][0]).toContain( + "steps/copy_database/runs/run-1/events?after=0", + ); + expect(reread).not.toHaveBeenCalled(); + + // The connection dropped. After a moment the page asks again for what came after the last line it saw. + await act(() => jest.advanceTimersByTimeAsync(1000)); + expect(fetched).toHaveBeenCalledTimes(1); + await act(() => jest.advanceTimersByTimeAsync(1000)); + + expect(fetched.mock.calls[1][0]).toContain("events?after=2"); + await waitFor(() => expect(reread).toHaveBeenCalledTimes(1)); + }); + + it("stops asking for a run the server no longer has, and reads the record again", async () => { + jest.useFakeTimers(); + const fetched = jest + .fn() + .mockResolvedValue({ ok: false, status: 404, body: null }); + global.fetch = fetched; + const client = new QueryClient(); + const reread = jest.spyOn(client, "invalidateQueries"); + render( + + {panel({ status: "running", finished_at: null }, "current")} + , + ); + + await waitFor(() => expect(reread).toHaveBeenCalledTimes(1)); + await act(() => jest.advanceTimersByTimeAsync(10000)); + expect(fetched).toHaveBeenCalledTimes(1); + }); + + it("asks before it stops a run, and lets go of the run when the page goes away", async () => { + const fetched = silent(); + global.fetch = fetched; + const stop = jest.spyOn(api, "delete").mockRejectedValue(unreachable()); + const { unmount } = show( + panel({ status: "running", finished_at: null }, "current"), + ); + + expect(screen.getByText("Starting…")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /^Copy/ }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Stop" })); + + const asked = screen.getByRole("dialog", { name: "Stop copying?" }); + expect(asked).toHaveTextContent("Nothing from a stopped copy is kept."); + await userEvent.click( + within(asked).getAllByRole("button", { name: "Cancel" })[0], + ); + expect(stop).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("button", { name: "Stop" })); + await userEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: "Stop" }), + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Something went wrong. Try again.", + ); + expect(stop).toHaveBeenCalledWith( + expect.stringContaining("steps/copy_database/runs/run-1"), + ); + + // Leaving the page stops listening and nothing else: the run is the server's. + const [, { signal }] = fetched.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(signal?.aborted).toBe(false); + unmount(); + expect(signal?.aborted).toBe(true); + }); + + it.each<[string, Partial, string, string | undefined]>([ + [ + "target_unreachable", + { + report: { + ok: false, + problems: [ + { code: "target_unreachable", message: "connection refused" }, + ], + }, + }, + "Can't reach the new instance's database. Check 'Where your data goes', then try again.", + "connection refused", + ], + [ + "target_not_empty", + { + report: { + ok: false, + problems: [ + { code: "target_not_empty", message: "at revision abc" }, + { code: "value_rejected", message: "bad enum" }, + ], + }, + }, + "The new instance's database isn't empty. Use a new, empty database, change it in 'Where your data goes', then copy again.", + "at revision abc bad enum", + ], + // The destination holds an earlier copy, and a row was deleted here since. Copying again cannot get past it. + [ + "count_mismatch", + { + report: { + ok: false, + problems: [ + { + code: "count_mismatch", + message: "file: source has 1 rows, target has 2 after copy", + }, + ], + }, + }, + "The new instance's database holds an earlier copy that no longer matches this instance. Copying into it again can't fix that. Save a new, empty database in 'Where your data goes', then copy again.", + "file: source has 1 rows, target has 2 after copy", + ], + [ + "crashed", + { + status: "failed", + error: { code: "crashed", message: "Traceback: boom" }, + }, + "The step stopped with an error. Run it again.", + "Traceback: boom", + ], + // A code this page has no words for reads as a failure, with the tool's own words under it. + [ + "copy_failed", + { + report: { + ok: false, + problems: [{ code: "copy_failed", message: "rolled back" }], + }, + }, + "The step stopped with an error. Run it again.", + "rolled back", + ], + [ + "cancelled", + { status: "cancelled", error: { code: "cancelled" } }, + "Stopped before it finished. Run it again.", + undefined, + ], + [ + "destination_changed", + { report: { ok: true, tables_copied: 59, rows_copied: 57 } }, + "The destination changed after this copy was made. Copy again.", + undefined, + ], + ])("says why the last copy does not count: %s", (reason, run, line, said) => { + show(panel(run, "blocked", reason)); + + const why = screen.getByRole("alert"); + expect(why).toHaveTextContent(line); + if (said) + expect(within(why).getByText(/./, { selector: "pre" })).toHaveTextContent( + said, + ); + else expect(why.querySelector("pre")).toBeNull(); + expect( + screen.getByRole("button", { name: "Copy again" }), + ).toBeInTheDocument(); + }); + + it("offers a done copy again, with nothing to explain", () => { + show( + panel( + { report: { ok: true, tables_copied: 59, rows_copied: 57 } }, + "done", + ), + ); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText(/out of date/)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Copy again" }), + ).toBeInTheDocument(); + }); + + it("says a copy the step no longer counts has to be made again", () => { + // The copy ended well, and the step waits again: the instance changed after it. + show( + panel( + { report: { ok: true, tables_copied: 59, rows_copied: 57 } }, + "current", + ), + ); + + expect( + screen.getByText( + "This copy is out of date, so it no longer counts. Copy again.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Copy again" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/catalog.ts b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/catalog.ts index 0021d79d86ee..0b5cef276147 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/catalog.ts +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/catalog.ts @@ -1,5 +1,7 @@ import type { AcceptedFinding, + CopyEvent, + CopyStepId, DestinationsRequest, MigrationCheck, MigrationState, @@ -163,3 +165,45 @@ function shellArgument(value: string) { ? value : `'${value.replaceAll("'", "'\\''")}'`; } + +/** The copy steps this page can run. Each slug names its copy under `settings.migration.*`. */ +export const COPIES: Partial> = { + copy_database: { slug: "copyDb" }, +}; + +export const isCopy = (id: MigrationStepId): id is CopyStepId => id in COPIES; + +/** + * The page's line, under `settings.migration.*`, for each reason a copy does not start or does not count. + * Any other code reads as a failure, with the command's own words under it. + */ +export const COPY_CODES: Record = { + secrets_missing: "error.enterAgain", // pragma: allowlist secret + run_active: "error.runningElsewhere", + cancelled: "error.interrupted", + interrupted: "error.interrupted", + crashed: "error.crashed", + destination_changed: "error.destinationChanged", + target_unreachable: "error.targetUnreachable", + target_not_empty: "error.dbTargetNotEmpty", + // The destination holds an earlier copy, and this instance lost a row since. Only a new, empty one takes a copy. + count_mismatch: "error.dbCountMismatch", + orphans_no_rule: "error.orphansNoRule", + value_rejected: "error.valueRejected", +}; + +/** How far a run has got: the key of its line under `settings.migration.*`, and the counts that fill it. */ +export function copyProgress( + step: CopyStepId, + event: CopyEvent, + language: string, +): [string, Record] { + if (event.phase === "checking") return ["copy.checking", {}]; + if (event.phase === "preparing_target") return ["copy.preparing", {}]; + const count = (value?: number | null) => + (value ?? 0).toLocaleString(language); + return [ + `${COPIES[step]?.slug}.progress`, + { done: count(event.done), total: count(event.total) }, + ]; +} diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/index.tsx b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/index.tsx index 2559de81ed1a..ee590d9476ec 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/index.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/index.tsx @@ -20,7 +20,8 @@ import { useUtilityStore } from "@/stores/utilityStore"; import { cn } from "@/utils/utils"; import { BackupStep } from "./BackupStep"; import { CheckStep } from "./CheckStep"; -import { formatTime, PARTS } from "./catalog"; +import { CopyStep } from "./CopyStep"; +import { formatTime, isCopy, PARTS } from "./catalog"; import { DestinationsStep } from "./DestinationsStep"; import { PausedBanner, PauseStep, Recovery } from "./PauseStep"; import { SecretKeyStep } from "./SecretKeyStep"; @@ -226,6 +227,9 @@ function Migration({ migration }: { migration: MigrationState }) { number += 1; const state = stateOf(step.id); const isCheck = step.id === "check_source"; + const copy = isCopy(step.id) + ? record.steps[step.id] + : undefined; let summary: ReactNode = t( `settings.migration.step.${step.slug}.purpose`, ); @@ -279,6 +283,18 @@ function Migration({ migration }: { migration: MigrationState }) { ), location: record.backup?.location, }); + } else if ( + step.id === "copy_database" && + state.state === "done" + ) { + summary = t("settings.migration.copyDb.done", { + tables: copy?.report?.tables_copied?.toLocaleString( + i18n.language, + ), + rows: copy?.report?.rows_copied?.toLocaleString( + i18n.language, + ), + }); } // A step the admin has reached, and that this server can do. const live = @@ -299,6 +315,14 @@ function Migration({ migration }: { migration: MigrationState }) { body = ; } else if (unfinished && step.id === "backup") { body = ; + } else if (live && isCopy(step.id)) { + body = ( + + ); } return ( diff --git a/src/frontend/tests/core/features/migration-copy.spec.ts b/src/frontend/tests/core/features/migration-copy.spec.ts new file mode 100644 index 000000000000..b78c47e64766 --- /dev/null +++ b/src/frontend/tests/core/features/migration-copy.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "../../fixtures"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; +import { + NO_DESTINATION, + readyToCopy, + startOver, +} from "../../utils/migration-walk"; + +test( + "an admin copies the database, stops a copy and copies again", + { tag: ["@release", "@workspace", "@api"] }, + async ({ page }) => { + // A copy needs changes paused, which refuses every change to the instance, so this walk can't share a server with other specs. + test.skip(Boolean(NO_DESTINATION), NO_DESTINATION); + await awaitBootstrapTest(page, { skipModal: true }); + const instance = await startOver(page); + test.skip( + instance.database.type !== "sqlite", + "Only an instance on SQLite has a database to copy.", + ); + await readyToCopy(page, instance); + + try { + await page.goto("/settings/migration"); + const copy = page.getByTestId("migration-step-copy_database"); + await copy.getByRole("button", { name: "Copy the database" }).click(); + + // The copy is the server's own process. The page shows what it says while it runs. + await expect(copy.getByRole("button", { name: "Stop" })).toBeVisible(); + await expect(copy).toContainText( + /Checking what there is to copy…|Preparing the new database…|Rows: [\d,]+ of [\d,]+|Tables: [\d,]+\./, + { timeout: 120000 }, + ); + + // A page that is loaded again finds the run where it is and follows it to its end. + await page.reload(); + await expect(copy).toContainText(/Tables: [\d,]+\. Rows: [\d,]+\./, { + timeout: 120000, + }); + const copied = ( + await (await page.request.get("/api/v1/migration")).json() + ).record.steps.copy_database.report; + expect(copied.ok).toBe(true); + expect(copied.rows_copied).toBeGreaterThan(0); + + // Copied again and stopped on the way: the step says so, and nothing of that copy counts. + const again = copy.getByRole("button", { name: "Copy again" }); + // A copy that ended while the page was loading shows closed, and its title opens it. + if (!(await again.isVisible())) + await copy.getByRole("button", { name: "Copy the database" }).click(); + await again.click(); + await copy.getByRole("button", { name: "Stop" }).click(); + await page + .getByRole("dialog", { name: "Stop copying?" }) + .getByRole("button", { name: "Stop", exact: true }) + .click(); + await expect(copy.getByRole("alert")).toContainText( + "Stopped before it finished. Run it again.", + { timeout: 60000 }, + ); + + await again.click(); + await expect(copy).toContainText(/Tables: [\d,]+\. Rows: [\d,]+\./, { + timeout: 120000, + }); + await expect(copy.getByRole("alert")).toHaveCount(0); + } finally { + // A walk that stops midway must leave no copy running and no pause on. + await startOver(page); + } + }, +); diff --git a/src/frontend/tests/utils/migration-walk.ts b/src/frontend/tests/utils/migration-walk.ts index 9b3062250ff2..bf6b4235db87 100644 --- a/src/frontend/tests/utils/migration-walk.ts +++ b/src/frontend/tests/utils/migration-walk.ts @@ -25,16 +25,22 @@ export const NO_DESTINATION = /** * Starts the move over and answers with this instance's facts. The migration record is a file next * to the instance's data, and a walk runs on the machine the server runs on, so it removes the file. - * That also ends a pause an earlier walk left on. + * That also ends a pause an earlier walk left on. A copy an earlier walk left running is stopped first, + * because the server runs one at a time. */ export async function startOver(page: Page) { - const read = async () => - (await (await page.request.get("/api/v1/migration")).json()).instance; - const { files } = await read(); - fs.rmSync(path.join(files.folder, "migrations", "migration.json"), { + const read = async () => (await page.request.get("/api/v1/migration")).json(); + const { instance, record } = await read(); + for (const [step, run] of Object.entries<{ run_id?: string }>(record.steps)) { + if (run.run_id) + await page.request.delete( + `/api/v1/migration/steps/${step}/runs/${run.run_id}`, + ); + } + fs.rmSync(path.join(instance.files.folder, "migrations", "migration.json"), { force: true, }); - return read(); + return (await read()).instance; } /** What the admin reads where the new instance's key is set: the first 12 characters of the key's SHA-256. */ @@ -76,3 +82,23 @@ export async function prepare( data: { fingerprint: fingerprint(instance.secret_key.path) }, }); } + +/** Does every step before the copies through the API: the ones before the pause, the pause with its second check, and the backup. */ +export async function readyToCopy( + page: Page, + instance: Awaited>, +) { + await prepare(page, instance); + await page.request.post("/api/v1/migration/pause"); + const check = await page.request.post("/api/v1/migration/checks", { + data: { target_version: instance.version }, + timeout: 120000, + }); + await check.body(); + // On SQLite the backup of the database is the copy the server hands out. + if (instance.database.type === "sqlite") + await page.request.post("/api/v1/migration/backup/database"); + await page.request.post("/api/v1/migration/steps/backup/confirm", { + data: { location: "a browser walk" }, + }); +} From 729681919caf18925132098171eddb2a5d8170ac Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 8 Oct 2026 08:30:37 -0700 Subject: [PATCH 2/2] fix(frontend): keep following a copy after a refusal and announce its end A JSON error body from the events request reached onData, whose false return aborted the controller every retry shares: no retry and no record re-read, so the step froze on its last progress line. Let the refusal body end on its own instead. Keep one role=status region mounted across the running and finished states so a screen reader hears the result of a copy it waited on. --- .../API/queries/migration/use-migration.ts | 5 +- .../pages/MigrationPage/CopyStep.tsx | 159 ++++++++++-------- .../MigrationPage/__tests__/steps.test.tsx | 62 ++++++- 3 files changed, 154 insertions(+), 72 deletions(-) diff --git a/src/frontend/src/controllers/API/queries/migration/use-migration.ts b/src/frontend/src/controllers/API/queries/migration/use-migration.ts index 7fe2d952ade2..e7278eda87b7 100644 --- a/src/frontend/src/controllers/API/queries/migration/use-migration.ts +++ b/src/frontend/src/controllers/API/queries/migration/use-migration.ts @@ -210,9 +210,10 @@ export async function followCopy({ refused = { status }; }, onData: async (data) => { - // A refusal answers with one JSON error body in place of the events. + // A refusal answers with one JSON error body in place of the events. Let it end on its own: + // returning false would abort the caller's controller, which every later attempt shares. if (!refused) onEvent(data as CopyEvent); - return !refused; + return true; }, onNetworkError: () => { if (!controller.signal.aborted) refused ??= { status: 0 }; diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx index 44e936f38919..8563f9afbefc 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/CopyStep.tsx @@ -46,50 +46,23 @@ export function CopyStep({ step: destinations, }); const refusal = start.error?.response?.data?.detail?.code ?? ""; - - if (run?.status === "running") { - const [key, counts] = progress - ? copyProgress(step, progress, i18n.language) - : ["copy.starting", {}]; - return ( -
-

- {t(`settings.migration.${key}`, counts)} -

- {stop.isError && ( -

- {t("settings.migration.failed")} -

- )} - - setConfirming(false)} - onCancel={() => setConfirming(false)} - title={t("settings.migration.copy.stopTitle")} - cancelText={t("modal.cancelButton")} - confirmationText={t("settings.migration.check.stop")} - onConfirm={() => { - setConfirming(false); - stop.mutate(run.run_id); - }} - size="x-small" - > - - {t(`settings.migration.${slug}.stopBody`)} - - -
- ); - } + const running = run?.status === "running"; + const [key, counts] = progress + ? copyProgress(step, progress, i18n.language) + : ["copy.starting", {}]; + // Whether this page watched the run, so it has an end to announce. + const [followed, setFollowed] = useState(false); + useEffect(() => { + if (running) setFollowed(true); + }, [running]); + // The step's row shows the result. This says it too, to a screen reader that waited on the copy. + const finished = + followed && step === "copy_database" && state.state === "done" + ? t("settings.migration.copyDb.done", { + tables: run?.report?.tables_copied?.toLocaleString(i18n.language), + rows: run?.report?.rows_copied?.toLocaleString(i18n.language), + }) + : ""; // The command's own words for a run that did not count. const said = @@ -97,35 +70,83 @@ export function CopyStep({ run?.report?.problems?.map((problem) => problem.message).join("\n"); // A copy that ended in a step that waits again no longer counts, so it is history. const stale = state.state === "current" && Boolean(run); + return (
-

- {t(`settings.migration.${slug}.body`)} + {/* One region for both states, so the end of a run is announced as well as its progress. */} +

+ {running ? t(`settings.migration.${key}`, counts) : finished}

- {stale &&

{t("settings.migration.copy.stale")}

} - {state.state === "blocked" && ( -
-

{line(state.reason)}

- {said &&
} -
- )} - {start.isError && ( -

- {refusal in COPY_CODES - ? line(refusal) - : t("settings.migration.failed")} -

+ {running ? ( + <> + {stop.isError && ( +

+ {t("settings.migration.failed")} +

+ )} + + setConfirming(false)} + onCancel={() => setConfirming(false)} + title={t("settings.migration.copy.stopTitle")} + cancelText={t("modal.cancelButton")} + confirmationText={t("settings.migration.check.stop")} + onConfirm={() => { + setConfirming(false); + stop.mutate(run.run_id); + }} + size="x-small" + > + + {t(`settings.migration.${slug}.stopBody`)} + + + + ) : ( + <> +

+ {t(`settings.migration.${slug}.body`)} +

+ {stale && ( +

{t("settings.migration.copy.stale")}

+ )} + {state.state === "blocked" && ( +
+

{line(state.reason)}

+ {said &&
} +
+ )} + {start.isError && ( +

+ {refusal in COPY_CODES + ? line(refusal) + : t("settings.migration.failed")} +

+ )} + + )} -
); } diff --git a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx index 4578e9e14d97..2bdfcad5fbe9 100644 --- a/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/MigrationPage/__tests__/steps.test.tsx @@ -1076,6 +1076,12 @@ describe("Copy the database", () => { }, }; }; + // A refusal as the server sends one: a status and one JSON error body. + const refusedStream = (status: number, detail: object) => ({ + ...stream([{ detail }]), + ok: false, + status, + }); // A stream that stays open and says nothing. const silent = () => jest.fn(() => new Promise(() => {})); @@ -1175,7 +1181,9 @@ describe("Copy the database", () => { jest.useFakeTimers(); const fetched = jest .fn() - .mockResolvedValue({ ok: false, status: 404, body: null }); + .mockImplementation(async () => + refusedStream(404, { code: "run_not_found" }), + ); global.fetch = fetched; const client = new QueryClient(); const reread = jest.spyOn(client, "invalidateQueries"); @@ -1188,6 +1196,58 @@ describe("Copy the database", () => { await waitFor(() => expect(reread).toHaveBeenCalledTimes(1)); await act(() => jest.advanceTimersByTimeAsync(10000)); expect(fetched).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Starting…")).toBeInTheDocument(); + }); + + it("asks again after any other refusal, and reads nothing from its body", async () => { + jest.useFakeTimers(); + const fetched = jest + .fn() + .mockImplementationOnce(async () => + refusedStream(500, { message: "boom" }), + ) + .mockImplementation(() => new Promise(() => {})); + global.fetch = fetched; + const client = new QueryClient(); + const reread = jest.spyOn(client, "invalidateQueries"); + render( + + {panel({ status: "running", finished_at: null }, "current")} + , + ); + + await waitFor(() => expect(fetched).toHaveBeenCalledTimes(1)); + await act(() => jest.advanceTimersByTimeAsync(2000)); + + expect(fetched).toHaveBeenCalledTimes(2); + expect(fetched.mock.calls[1][0]).toContain("events?after=0"); + expect(reread).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent("Starting…"); + }); + + it("says when a copy it was following has ended", () => { + global.fetch = silent(); + const client = new QueryClient(); + const { rerender } = render( + + {panel({ status: "running", finished_at: null }, "current")} + , + ); + const region = screen.getByRole("status"); + expect(region).toHaveTextContent("Starting…"); + + rerender( + + {panel( + { report: { ok: true, tables_copied: 59, rows_copied: 1234 } }, + "done", + )} + , + ); + + // The same region stays mounted, so a screen reader hears the result. + expect(screen.getByRole("status")).toBe(region); + expect(region).toHaveTextContent("Tables: 59. Rows: 1,234."); }); it("asks before it stops a run, and lets go of the run when the page goes away", async () => {