diff --git a/components/servers/ServerForm.vue b/components/servers/ServerForm.vue index 42b23e408..d20b680c0 100644 --- a/components/servers/ServerForm.vue +++ b/components/servers/ServerForm.vue @@ -474,6 +474,23 @@ const showConnectPassword = ref(false); {{ $t("server.form.game_server_node_description") }} +
+ + {{ $t("server.form.move_node_hint") }} + + +
@@ -579,12 +596,16 @@ const SERVER_TYPE_PRACTICE = "Practice"; const SERVER_TYPE_CUSTOM = "Custom"; export default { - emits: ["updated"], + emits: ["updated", "move"], props: { server: { type: Object, required: false, }, + canMove: { + type: Boolean, + default: false, + }, }, apollo: { server_regions: { @@ -863,6 +884,9 @@ export default { mode.enabled && (mode.supported_runtimes ?? []).includes(runtime), ); }, + canMoveNode() { + return this.isEditingGameServerNode && this.canMove; + }, isEditingGameServerNode() { return !!(this.server && this.server.game_server_node_id); }, @@ -1019,7 +1043,7 @@ export default { formValues.game_server_node_id = null; } - await this.$apollo.mutate({ + const { data } = await this.$apollo.mutate({ mutation: generateMutation({ update_servers_by_pk: [ { @@ -1049,6 +1073,18 @@ export default { ], }), }); + + if (!data?.update_servers_by_pk) { + toast({ + variant: "destructive", + title: this.$t("common.error"), + description: this.$t( + "pages.dedicated_servers.detail.migration.locked", + ), + }); + return; + } + this.takeSnapshot(); this.$emit("updated"); return; diff --git a/components/servers/ServerMigrationPanel.vue b/components/servers/ServerMigrationPanel.vue new file mode 100644 index 000000000..74fc8c148 --- /dev/null +++ b/components/servers/ServerMigrationPanel.vue @@ -0,0 +1,307 @@ + + + diff --git a/components/servers/ServerMoveDialog.vue b/components/servers/ServerMoveDialog.vue new file mode 100644 index 000000000..a620917da --- /dev/null +++ b/components/servers/ServerMoveDialog.vue @@ -0,0 +1,322 @@ + + + diff --git a/composables/useServerMigration.ts b/composables/useServerMigration.ts new file mode 100644 index 000000000..adf433420 --- /dev/null +++ b/composables/useServerMigration.ts @@ -0,0 +1,124 @@ +import { computed, onMounted, onUnmounted, ref, watch, type Ref } from "vue"; +import getGraphqlClient from "~/graphql/getGraphqlClient"; +import { useSubscriptionManager } from "~/composables/useSubscriptionManager"; +import { + SERVER_MIGRATION_OPTIONAL, + SERVER_MIGRATION_SUBSCRIPTION, + SERVER_MOVE_NODES_SUBSCRIPTION, +} from "~/graphql/serverMigrationGraphql"; +import { + ACTIVE_SERVER_MIGRATION_STATUSES, + isNodeUp, + type MigratingServer, + type ServerMoveNode, +} from "~/types/serverMigration"; + +let instances = 0; + +export function useServerMigration( + serverId: Ref, + enabled: Ref, +) { + const key = `server-migration:${++instances}`; + const server = ref(null); + const { subscribe, unsubscribe } = useSubscriptionManager(); + + function start([id, on]: readonly [string | null | undefined, boolean]) { + unsubscribe(key); + server.value = null; + + if (!id || !on) { + return; + } + + subscribe( + key, + getGraphqlClient() + .subscribe({ + query: SERVER_MIGRATION_SUBSCRIPTION, + variables: { server_id: id }, + context: SERVER_MIGRATION_OPTIONAL, + }) + .subscribe({ + next: ({ data }) => { + server.value = (data as any)?.servers_by_pk ?? null; + }, + error: () => { + server.value = null; + }, + }), + ); + } + + onMounted(() => { + watch(() => [serverId.value, enabled.value] as const, start, { + immediate: true, + }); + }); + + onUnmounted(() => unsubscribe(key)); + + const migration = computed(() => server.value?.migrations?.[0] ?? null); + + const isMigrating = computed( + () => + !!migration.value && + ACTIVE_SERVER_MIGRATION_STATUSES.includes(migration.value.status), + ); + + const sourceReachable = computed(() => + isNodeUp(server.value?.game_server_node), + ); + + return { + server, + node: computed(() => server.value?.game_server_node ?? null), + migration, + isMigrating, + sourceReachable, + }; +} + +export function useServerMoveNodes(enabled: Ref) { + const key = `server-move-nodes:${++instances}`; + const nodes = ref>([]); + const loaded = ref(false); + const { subscribe, unsubscribe } = useSubscriptionManager(); + + function start(on: boolean) { + unsubscribe(key); + loaded.value = false; + + if (!on) { + nodes.value = []; + return; + } + + subscribe( + key, + getGraphqlClient() + .subscribe({ + query: SERVER_MOVE_NODES_SUBSCRIPTION, + context: SERVER_MIGRATION_OPTIONAL, + }) + .subscribe({ + next: ({ data }) => { + nodes.value = (data as any)?.game_server_nodes ?? []; + loaded.value = true; + }, + error: () => { + nodes.value = []; + loaded.value = true; + }, + }), + ); + } + + onMounted(() => { + watch(enabled, start, { immediate: true }); + }); + + onUnmounted(() => unsubscribe(key)); + + return { nodes, loaded }; +} diff --git a/graphql/serverMigrationGraphql.ts b/graphql/serverMigrationGraphql.ts new file mode 100644 index 000000000..534f896ed --- /dev/null +++ b/graphql/serverMigrationGraphql.ts @@ -0,0 +1,92 @@ +import gql from "graphql-tag"; + +// Raw documents: server_migrations and available_dedicated_slot_count are newer +// than the generated zeus client. Subscribe with SERVER_MIGRATION_OPTIONAL so +// web shipping ahead of the api's metadata hides the move instead of erroring. +export const SERVER_MIGRATION_OPTIONAL = { optional: true }; + +export const SERVER_MIGRATION_SUBSCRIPTION = gql` + subscription ServerMigration($server_id: uuid!) { + servers_by_pk(id: $server_id) { + id + game + region + plugin_runtime + game_server_node_id + game_server_node { + id + label + status + node_ip + offline_at + pin_plugin_runtime + } + migrations(order_by: { created_at: desc }, limit: 1) { + id + status + with_files + bytes_total + bytes_done + error + warnings + created_at + started_at + finished_at + from_game_server_node { + id + label + } + to_game_server_node { + id + label + } + } + } + } +`; + +export const SERVER_MOVE_NODES_SUBSCRIPTION = gql` + subscription ServerMoveNodes { + game_server_nodes(order_by: [{ label: asc }, { id: asc }]) { + id + label + status + node_ip + enabled + build_id + csgo_build_id + update_status + region + e_region { + description + } + disk_available_gb + available_dedicated_slot_count + pin_plugin_runtime + } + } +`; + +export const MOVE_DEDICATED_SERVER_MUTATION = gql` + mutation MoveDedicatedServerToNode( + $server_id: uuid! + $game_server_node_id: String! + $without_files: Boolean + ) { + moveDedicatedServerToNode( + server_id: $server_id + game_server_node_id: $game_server_node_id + without_files: $without_files + ) { + success + } + } +`; + +export const CANCEL_DEDICATED_SERVER_MOVE_MUTATION = gql` + mutation CancelDedicatedServerMove($server_id: uuid!) { + cancelDedicatedServerMove(server_id: $server_id) { + success + } + } +`; diff --git a/i18n/locales/ar_SA.json b/i18n/locales/ar_SA.json index 1dd7f1755..ad6785427 100644 --- a/i18n/locales/ar_SA.json +++ b/i18n/locales/ar_SA.json @@ -3752,6 +3752,58 @@ "browse": "دليل الإضافات", "saved": "تم حفظ الإضافات. تتم إعادة تشغيل الخادم.", "saved_disabled": "تم حفظ الإضافات. ستُطبَّق عند تفعيل الخادم." + }, + "move": { + "menu": "النقل إلى عقدة أخرى", + "title": "النقل إلى عقدة أخرى", + "description": "يتوقف الخادم على {node}، وتُنسخ ملفاته إلى العقدة الجديدة، ثم يعمل مجددًا هناك.", + "source_offline": "{node} غير متصلة، لذا لا يمكن نسخ ملفات الخادم. سيبدأ على العقدة الجديدة بالإعدادات الافتراضية، وكل ما يبقى على {node} سيظل هناك.", + "without_files": "النقل بدون ملفات الخادم", + "target": "العقدة الجديدة", + "no_nodes": "لا توجد عقد سيرفرات أخرى", + "free_slots": "الخانات الشاغرة: {count}", + "free_disk": "{size} متاحة", + "confirm": "نقل الخادم", + "started": "جارٍ نقل {server} إلى {node}", + "warning": { + "address": "سيحصل الخادم على عنوان جديد على العقدة الجديدة.", + "region": "سيظهر الخادم في {region}.", + "runtime": "تعمل العقدة الجديدة بـ {runtime}، لذا لن تُحمَّل عليها الإضافات المصمّمة للإطار الآخر." + }, + "reason": { + "current": "العقدة الحالية", + "disabled": "معطّلة", + "offline": "غير متصل", + "setup": "إعداد", + "unreachable": "يتعذر الوصول إليها", + "updating": "يجري تحديث CS2", + "no_build": "لم يتم تثبيت {game}", + "no_slot": "لا توجد خانات خوادم شاغرة" + } + }, + "migration": { + "title": { + "active": "جارٍ النقل إلى {node}", + "Completed": "تم النقل إلى {node}", + "Failed": "فشل النقل إلى {node}", + "Canceled": "أُلغي النقل إلى {node}" + }, + "status": { + "Queued": "في الطابور", + "Stopping": "جارٍ الإيقاف", + "Transferring": "جارٍ نسخ الملفات", + "Finalizing": "جارٍ التبديل", + "Completed": "مكتمل", + "Failed": "فشل", + "Canceled": "ملغى" + }, + "progress": "{done} من {total} ({percent}%)", + "without_files": "بدون ملفات", + "restored": "عاد الخادم إلى {node}.", + "cancel": "إلغاء النقل", + "cancel_requested": "جارٍ إلغاء النقل", + "dismiss": "إخفاء", + "locked": "يتم نقل الخادم إلى عقدة أخرى. حاول مرة أخرى بعد انتهاء النقل." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "سيرفر", "missing_path": "هذا المجلد غير موجود بعد.", "missing_path_create": "أنشئه", - "missing_path_dismiss": "تجاهل" + "missing_path_dismiss": "تجاهل", + "server_moving": "يجري نقل هذا الخادم إلى عقدة أخرى. ستتوفر ملفاته مجددًا بعد اكتمال النقل." }, "error": { "generic": "حدث خطأ، حاول مرة أخرى." @@ -8599,6 +8652,8 @@ "game_server_node": "عقدة سيرفرات", "select_game_server_node": "اختر عقدة سيرفرات", "game_server_node_description": "اختر عقدة سيرفرات مثبّت عليها CS", + "move_node_hint": "لتغيير العقدة، انقل الخادم. تُنسخ ملفاته معه.", + "move_node": "نقل الخادم", "type": "نوع السيرفر", "select_type": "اختر نوع السيرفر", "game_mode": "نمط اللعب", diff --git a/i18n/locales/da_DK.json b/i18n/locales/da_DK.json index 18a754ccc..e674222dd 100644 --- a/i18n/locales/da_DK.json +++ b/i18n/locales/da_DK.json @@ -3752,6 +3752,58 @@ "browse": "Plugin-oversigt", "saved": "Plugins gemt. Serveren genstarter.", "saved_disabled": "Plugins gemt. De gælder, når serveren er slået til." + }, + "move": { + "menu": "Flyt til en anden node", + "title": "Flyt til en anden node", + "description": "Serveren stopper på {node}, dens filer kopieres til den nye node, og den starter igen der.", + "source_offline": "{node} er offline, så serverens filer kan ikke kopieres. Den starter på den nye node med standardindstillinger, og alt, der er tilbage på {node}, bliver liggende der.", + "without_files": "Flyt uden serverens filer", + "target": "Ny node", + "no_nodes": "Der er ingen andre game server noder", + "free_slots": "Ledige pladser: {count}", + "free_disk": "{size} ledig", + "confirm": "Flyt server", + "started": "Flytter {server} til {node}", + "warning": { + "address": "Serveren får en ny adresse på den nye node.", + "region": "Serveren bliver vist i {region}.", + "runtime": "Den nye node kører {runtime}, så plugins bygget til det andet framework indlæses ikke der." + }, + "reason": { + "current": "Nuværende node", + "disabled": "Deaktiveret", + "offline": "Offline", + "setup": "Opsætning", + "unreachable": "Kan ikke nås", + "updating": "Opdaterer CS2", + "no_build": "{game} er ikke installeret", + "no_slot": "Ingen ledige serverpladser" + } + }, + "migration": { + "title": { + "active": "Flytter til {node}", + "Completed": "Flyttet til {node}", + "Failed": "Flytning til {node} mislykkedes", + "Canceled": "Flytning til {node} annulleret" + }, + "status": { + "Queued": "I kø", + "Stopping": "Stopper", + "Transferring": "Kopierer filer", + "Finalizing": "Skifter over", + "Completed": "Fuldført", + "Failed": "Mislykkedes", + "Canceled": "Annulleret" + }, + "progress": "{done} af {total} ({percent}%)", + "without_files": "uden filer", + "restored": "Serveren er tilbage på {node}.", + "cancel": "Annuller flytning", + "cancel_requested": "Annullerer flytningen", + "dismiss": "Luk", + "locked": "Serveren flyttes til en anden node. Prøv igen, når flytningen er færdig." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Server", "missing_path": "Denne mappe findes ikke endnu.", "missing_path_create": "Opret den", - "missing_path_dismiss": "Afvis" + "missing_path_dismiss": "Afvis", + "server_moving": "Denne server er ved at blive flyttet til en anden node. Dens filer er tilgængelige igen, når flytningen er færdig." }, "error": { "generic": "Der opstod en fejl. Prøv igen." @@ -8599,6 +8652,8 @@ "game_server_node": "Game server node", "select_game_server_node": "Vælg game server node", "game_server_node_description": "Vælg en game server node med CS installeret", + "move_node_hint": "Flyt serveren for at skifte node. Dens filer kopieres med.", + "move_node": "Flyt server", "type": "Servertype", "select_type": "Vælg servertype", "game_mode": "Spiltilstand", diff --git a/i18n/locales/de_DE.json b/i18n/locales/de_DE.json index 9e65a7b94..2026b23ad 100644 --- a/i18n/locales/de_DE.json +++ b/i18n/locales/de_DE.json @@ -3752,6 +3752,58 @@ "browse": "Plugin-Verzeichnis", "saved": "Plugins gespeichert. Der Server startet neu.", "saved_disabled": "Plugins gespeichert. Sie gelten, sobald der Server aktiviert ist." + }, + "move": { + "menu": "Auf andere Node verschieben", + "title": "Auf andere Node verschieben", + "description": "Der Server wird auf {node} gestoppt, seine Dateien werden auf die neue Node kopiert, und dort startet er erneut.", + "source_offline": "{node} ist offline, daher können die Dateien des Servers nicht kopiert werden. Er startet auf der neuen Node mit Standardeinstellungen, und alles, was auf {node} liegt, bleibt dort.", + "without_files": "Ohne die Dateien des Servers verschieben", + "target": "Neue Node", + "no_nodes": "Es gibt keine anderen Game-Server-Nodes", + "free_slots": "Freie Slots: {count}", + "free_disk": "{size} frei", + "confirm": "Server verschieben", + "started": "{server} wird auf {node} verschoben", + "warning": { + "address": "Der Server erhält auf der neuen Node eine neue Adresse.", + "region": "Der Server wird in {region} gelistet.", + "runtime": "Auf der neuen Node läuft {runtime}, daher werden Plugins für das andere Framework dort nicht geladen." + }, + "reason": { + "current": "Aktuelle Node", + "disabled": "Deaktiviert", + "offline": "Offline", + "setup": "Einrichtung", + "unreachable": "Nicht erreichbar", + "updating": "Aktualisiert CS2", + "no_build": "{game} ist nicht installiert", + "no_slot": "Keine freien Server-Slots" + } + }, + "migration": { + "title": { + "active": "Wird auf {node} verschoben", + "Completed": "Auf {node} verschoben", + "Failed": "Verschieben auf {node} fehlgeschlagen", + "Canceled": "Verschieben auf {node} abgebrochen" + }, + "status": { + "Queued": "In Warteschlange", + "Stopping": "Wird gestoppt", + "Transferring": "Dateien werden kopiert", + "Finalizing": "Wird umgeschaltet", + "Completed": "Abgeschlossen", + "Failed": "Fehlgeschlagen", + "Canceled": "Abgebrochen" + }, + "progress": "{done} von {total} ({percent}%)", + "without_files": "ohne Dateien", + "restored": "Der Server ist wieder auf {node}.", + "cancel": "Verschieben abbrechen", + "cancel_requested": "Verschieben wird abgebrochen", + "dismiss": "Ausblenden", + "locked": "Der Server wird auf eine andere Node verschoben. Versuche es erneut, sobald der Umzug abgeschlossen ist." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Server", "missing_path": "Dieses Verzeichnis existiert noch nicht.", "missing_path_create": "Anlegen", - "missing_path_dismiss": "Ausblenden" + "missing_path_dismiss": "Ausblenden", + "server_moving": "Dieser Server wird gerade auf eine andere Node verschoben. Seine Dateien sind wieder verfügbar, sobald das Verschieben abgeschlossen ist." }, "error": { "generic": "Ein Fehler ist aufgetreten. Versuch es erneut." @@ -8599,6 +8652,8 @@ "game_server_node": "Game-Server-Node", "select_game_server_node": "Game-Server-Node wählen", "game_server_node_description": "Wähle eine Game-Server-Node mit installiertem CS", + "move_node_hint": "Um die Node zu wechseln, verschiebe den Server. Seine Dateien werden mitkopiert.", + "move_node": "Server verschieben", "type": "Server-Typ", "select_type": "Server-Typ wählen", "game_mode": "Spielmodus", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 29927fc66..a27a9a3fc 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -3752,6 +3752,58 @@ "browse": "Plugin Directory", "saved": "Plugins saved. The server is restarting.", "saved_disabled": "Plugins saved. They apply when the server is enabled." + }, + "move": { + "menu": "Move to Another Node", + "title": "Move to Another Node", + "description": "The server stops on {node}, its files are copied to the new node, and it starts again there.", + "source_offline": "{node} is offline, so the server's files can't be copied. It will start on the new node with default settings, and anything left on {node} stays there.", + "without_files": "Move without the server's files", + "target": "New node", + "no_nodes": "There are no other game server nodes", + "free_slots": "Free slots: {count}", + "free_disk": "{size} free", + "confirm": "Move Server", + "started": "Moving {server} to {node}", + "warning": { + "address": "The server gets a new address on the new node.", + "region": "The server will be listed in {region}.", + "runtime": "The new node runs {runtime}, so plugins built for the other framework won't load there." + }, + "reason": { + "current": "Current node", + "disabled": "Disabled", + "offline": "Offline", + "setup": "Setting up", + "unreachable": "Unreachable", + "updating": "Updating CS2", + "no_build": "{game} is not installed", + "no_slot": "No free server slots" + } + }, + "migration": { + "title": { + "active": "Moving to {node}", + "Completed": "Moved to {node}", + "Failed": "Move to {node} failed", + "Canceled": "Move to {node} canceled" + }, + "status": { + "Queued": "Queued", + "Stopping": "Stopping", + "Transferring": "Copying Files", + "Finalizing": "Switching", + "Completed": "Completed", + "Failed": "Failed", + "Canceled": "Canceled" + }, + "progress": "{done} of {total} ({percent}%)", + "without_files": "without files", + "restored": "The server is back on {node}.", + "cancel": "Cancel Move", + "cancel_requested": "Canceling the move", + "dismiss": "Dismiss", + "locked": "The server is being moved to another node. Try again once the move finishes." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Server", "missing_path": "This directory doesn't exist yet.", "missing_path_create": "Create it", - "missing_path_dismiss": "Dismiss" + "missing_path_dismiss": "Dismiss", + "server_moving": "This server is being moved to another node. Its files will be available again once the move finishes." }, "error": { "generic": "An error occurred. Please try again." @@ -8599,6 +8652,8 @@ "game_server_node": "Game Server Node", "select_game_server_node": "Select Game Server Node", "game_server_node_description": "Select a game server node with CS installed", + "move_node_hint": "To change the node, move the server. Its files are copied across.", + "move_node": "Move Server", "type": "Server Type", "select_type": "Select Server Type", "game_mode": "Game Mode", diff --git a/i18n/locales/es_ES.json b/i18n/locales/es_ES.json index f132938ea..ad000d252 100644 --- a/i18n/locales/es_ES.json +++ b/i18n/locales/es_ES.json @@ -3752,6 +3752,58 @@ "browse": "Directorio de plugins", "saved": "Plugins guardados. El servidor se está reiniciando.", "saved_disabled": "Plugins guardados. Se aplicarán cuando el servidor esté activado." + }, + "move": { + "menu": "Mover a otro nodo", + "title": "Mover a otro nodo", + "description": "El servidor se detiene en {node}, sus archivos se copian al nuevo nodo y vuelve a arrancar allí.", + "source_offline": "{node} está desconectado, así que no se pueden copiar los archivos del servidor. Arrancará en el nuevo nodo con la configuración predeterminada, y lo que quede en {node} se quedará allí.", + "without_files": "Mover sin los archivos del servidor", + "target": "Nuevo nodo", + "no_nodes": "No hay otros nodos de servidores", + "free_slots": "Espacios libres: {count}", + "free_disk": "{size} libres", + "confirm": "Mover servidor", + "started": "Moviendo {server} a {node}", + "warning": { + "address": "El servidor tendrá una nueva dirección en el nuevo nodo.", + "region": "El servidor aparecerá en {region}.", + "runtime": "El nuevo nodo ejecuta {runtime}, así que los plugins creados para el otro framework no se cargarán allí." + }, + "reason": { + "current": "Nodo actual", + "disabled": "Desactivado", + "offline": "Desconectado", + "setup": "Configuración", + "unreachable": "Inaccesible", + "updating": "Actualizando CS2", + "no_build": "{game} no está instalado", + "no_slot": "No hay espacios de servidor libres" + } + }, + "migration": { + "title": { + "active": "Moviendo a {node}", + "Completed": "Movido a {node}", + "Failed": "Error al mover a {node}", + "Canceled": "Traslado a {node} cancelado" + }, + "status": { + "Queued": "En cola", + "Stopping": "Deteniendo", + "Transferring": "Copiando archivos", + "Finalizing": "Cambiando de nodo", + "Completed": "Completado", + "Failed": "Fallido", + "Canceled": "Cancelado" + }, + "progress": "{done} de {total} ({percent}%)", + "without_files": "sin archivos", + "restored": "El servidor vuelve a estar en {node}.", + "cancel": "Cancelar traslado", + "cancel_requested": "Cancelando el traslado", + "dismiss": "Descartar", + "locked": "El servidor se está moviendo a otro nodo. Inténtalo de nuevo cuando termine el traslado." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Servidor", "missing_path": "Este directorio todavía no existe.", "missing_path_create": "Crearlo", - "missing_path_dismiss": "Descartar" + "missing_path_dismiss": "Descartar", + "server_moving": "Este servidor se está moviendo a otro nodo. Sus archivos volverán a estar disponibles cuando termine el traslado." }, "error": { "generic": "Se ha producido un error. Inténtalo de nuevo." @@ -8599,6 +8652,8 @@ "game_server_node": "Nodo de servidores", "select_game_server_node": "Selecciona un nodo de servidores", "game_server_node_description": "Selecciona un nodo de servidores con CS instalado", + "move_node_hint": "Para cambiar de nodo, mueve el servidor. Sus archivos se copian al nuevo nodo.", + "move_node": "Mover servidor", "type": "Tipo de servidor", "select_type": "Selecciona el tipo de servidor", "game_mode": "Modo de juego", diff --git a/i18n/locales/fr_FR.json b/i18n/locales/fr_FR.json index 9e3010e60..b9a5586a4 100644 --- a/i18n/locales/fr_FR.json +++ b/i18n/locales/fr_FR.json @@ -3752,6 +3752,58 @@ "browse": "Répertoire des plugins", "saved": "Plugins enregistrés. Le serveur redémarre.", "saved_disabled": "Plugins enregistrés. Ils s'appliqueront quand le serveur sera activé." + }, + "move": { + "menu": "Déplacer vers un autre nœud", + "title": "Déplacer vers un autre nœud", + "description": "Le serveur s'arrête sur {node}, ses fichiers sont copiés vers le nouveau nœud, puis il redémarre là-bas.", + "source_offline": "{node} est hors ligne, les fichiers du serveur ne peuvent donc pas être copiés. Il démarrera sur le nouveau nœud avec les paramètres par défaut, et tout ce qui reste sur {node} y restera.", + "without_files": "Déplacer sans les fichiers du serveur", + "target": "Nouveau nœud", + "no_nodes": "Il n'y a aucun autre nœud de serveurs de jeu", + "free_slots": "Emplacements libres : {count}", + "free_disk": "{size} libres", + "confirm": "Déplacer le serveur", + "started": "Déplacement de {server} vers {node}", + "warning": { + "address": "Le serveur obtient une nouvelle adresse sur le nouveau nœud.", + "region": "Le serveur sera répertorié dans {region}.", + "runtime": "Le nouveau nœud utilise {runtime}, les plugins conçus pour l'autre framework n'y seront donc pas chargés." + }, + "reason": { + "current": "Nœud actuel", + "disabled": "Désactivé", + "offline": "Hors ligne", + "setup": "Configuration", + "unreachable": "Injoignable", + "updating": "Mise à jour de CS2", + "no_build": "{game} n'est pas installé", + "no_slot": "Aucun emplacement de serveur libre" + } + }, + "migration": { + "title": { + "active": "Déplacement vers {node}", + "Completed": "Déplacé vers {node}", + "Failed": "Échec du déplacement vers {node}", + "Canceled": "Déplacement vers {node} annulé" + }, + "status": { + "Queued": "En file d'attente", + "Stopping": "Arrêt en cours", + "Transferring": "Copie des fichiers", + "Finalizing": "Basculement", + "Completed": "Terminé", + "Failed": "Échec", + "Canceled": "Annulé" + }, + "progress": "{done} sur {total} ({percent}%)", + "without_files": "sans fichiers", + "restored": "Le serveur est de retour sur {node}.", + "cancel": "Annuler le déplacement", + "cancel_requested": "Annulation du déplacement", + "dismiss": "Masquer", + "locked": "Le serveur est en cours de déplacement vers un autre nœud. Réessaie une fois le déplacement terminé." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Serveur", "missing_path": "Ce répertoire n'existe pas encore.", "missing_path_create": "Le créer", - "missing_path_dismiss": "Ignorer" + "missing_path_dismiss": "Ignorer", + "server_moving": "Ce serveur est en cours de déplacement vers un autre nœud. Ses fichiers seront de nouveau disponibles une fois le déplacement terminé." }, "error": { "generic": "Une erreur est survenue. Réessaie." @@ -8599,6 +8652,8 @@ "game_server_node": "Nœud de serveurs de jeu", "select_game_server_node": "Sélectionner un nœud de serveurs de jeu", "game_server_node_description": "Sélectionne un nœud de serveurs de jeu avec CS installé", + "move_node_hint": "Pour changer de nœud, déplace le serveur. Ses fichiers sont copiés avec lui.", + "move_node": "Déplacer le serveur", "type": "Type de serveur", "select_type": "Sélectionner le type de serveur", "game_mode": "Mode de jeu", diff --git a/i18n/locales/it_IT.json b/i18n/locales/it_IT.json index 0bf4dba67..c42f2ee40 100644 --- a/i18n/locales/it_IT.json +++ b/i18n/locales/it_IT.json @@ -3752,6 +3752,58 @@ "browse": "Elenco plugin", "saved": "Plugin salvati. Il server si sta riavviando.", "saved_disabled": "Plugin salvati. Si applicheranno quando il server sarà attivato." + }, + "move": { + "menu": "Sposta su un altro nodo", + "title": "Sposta su un altro nodo", + "description": "Il server si arresta su {node}, i suoi file vengono copiati sul nuovo nodo e poi si riavvia lì.", + "source_offline": "{node} è offline, quindi i file del server non possono essere copiati. Si avvierà sul nuovo nodo con le impostazioni predefinite e ciò che resta su {node} rimarrà lì.", + "without_files": "Sposta senza i file del server", + "target": "Nuovo nodo", + "no_nodes": "Non ci sono altri nodi server di gioco", + "free_slots": "Slot liberi: {count}", + "free_disk": "{size} liberi", + "confirm": "Sposta server", + "started": "Spostamento di {server} su {node}", + "warning": { + "address": "Il server riceve un nuovo indirizzo sul nuovo nodo.", + "region": "Il server sarà elencato in {region}.", + "runtime": "Il nuovo nodo usa {runtime}, quindi i plugin creati per l'altro framework non verranno caricati lì." + }, + "reason": { + "current": "Nodo attuale", + "disabled": "Disattivato", + "offline": "Offline", + "setup": "Configurazione", + "unreachable": "Non raggiungibile", + "updating": "Aggiornamento CS2", + "no_build": "{game} non è installato", + "no_slot": "Nessuno slot server libero" + } + }, + "migration": { + "title": { + "active": "Spostamento su {node}", + "Completed": "Spostato su {node}", + "Failed": "Spostamento su {node} non riuscito", + "Canceled": "Spostamento su {node} annullato" + }, + "status": { + "Queued": "In coda", + "Stopping": "Arresto in corso", + "Transferring": "Copia dei file", + "Finalizing": "Passaggio al nuovo nodo", + "Completed": "Completato", + "Failed": "Non riuscito", + "Canceled": "Annullato" + }, + "progress": "{done} di {total} ({percent}%)", + "without_files": "senza file", + "restored": "Il server è di nuovo su {node}.", + "cancel": "Annulla spostamento", + "cancel_requested": "Annullamento dello spostamento", + "dismiss": "Chiudi", + "locked": "Il server è in fase di spostamento su un altro nodo. Riprova al termine dello spostamento." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Server", "missing_path": "Questa directory non esiste ancora.", "missing_path_create": "Creala", - "missing_path_dismiss": "Ignora" + "missing_path_dismiss": "Ignora", + "server_moving": "Questo server è in fase di spostamento su un altro nodo. I suoi file saranno di nuovo disponibili al termine dello spostamento." }, "error": { "generic": "Si è verificato un errore. Riprova." @@ -8599,6 +8652,8 @@ "game_server_node": "Nodo server di gioco", "select_game_server_node": "Seleziona nodo server di gioco", "game_server_node_description": "Seleziona un nodo server di gioco con CS installato", + "move_node_hint": "Per cambiare nodo, sposta il server. I suoi file vengono copiati.", + "move_node": "Sposta server", "type": "Tipo di server", "select_type": "Seleziona tipo di server", "game_mode": "Modalità di gioco", diff --git a/i18n/locales/ja_JP.json b/i18n/locales/ja_JP.json index 40d406ea7..2fd76ec48 100644 --- a/i18n/locales/ja_JP.json +++ b/i18n/locales/ja_JP.json @@ -3752,6 +3752,58 @@ "browse": "プラグインディレクトリ", "saved": "プラグインを保存しました。サーバーを再起動しています。", "saved_disabled": "プラグインを保存しました。サーバーを有効にすると適用されます。" + }, + "move": { + "menu": "別のノードへ移動", + "title": "別のノードへ移動", + "description": "サーバーは{node}上で停止し、ファイルが新しいノードにコピーされた後、そこで再び起動します。", + "source_offline": "{node}がオフラインのため、サーバーのファイルをコピーできません。新しいノードではデフォルト設定で起動し、{node}に残ったものはそのまま残ります。", + "without_files": "サーバーのファイルなしで移動", + "target": "新しいノード", + "no_nodes": "他のゲームサーバーノードがありません", + "free_slots": "空きスロット: {count}", + "free_disk": "空き {size}", + "confirm": "サーバーを移動", + "started": "{server}を{node}へ移動しています", + "warning": { + "address": "新しいノードではサーバーのアドレスが変わります。", + "region": "サーバーは{region}に表示されます。", + "runtime": "新しいノードは{runtime}で動作するため、もう一方のフレームワーク向けに作られたプラグインは読み込まれません。" + }, + "reason": { + "current": "現在のノード", + "disabled": "無効", + "offline": "オフライン", + "setup": "セットアップ", + "unreachable": "接続できません", + "updating": "CS2を更新中", + "no_build": "{game}がインストールされていません", + "no_slot": "空きサーバースロットがありません" + } + }, + "migration": { + "title": { + "active": "{node}へ移動中", + "Completed": "{node}へ移動しました", + "Failed": "{node}への移動に失敗しました", + "Canceled": "{node}への移動をキャンセルしました" + }, + "status": { + "Queued": "待機中", + "Stopping": "停止中", + "Transferring": "ファイルをコピー中", + "Finalizing": "切り替え中", + "Completed": "完了", + "Failed": "失敗", + "Canceled": "キャンセル済み" + }, + "progress": "{done} / {total} ({percent}%)", + "without_files": "ファイルなし", + "restored": "サーバーは{node}に戻りました。", + "cancel": "移動をキャンセル", + "cancel_requested": "移動をキャンセルしています", + "dismiss": "閉じる", + "locked": "サーバーを別のノードへ移動中です。移動が完了してからもう一度お試しください。" } } }, @@ -5875,7 +5927,8 @@ "scope_server": "サーバー", "missing_path": "このディレクトリはまだ存在しません。", "missing_path_create": "作成する", - "missing_path_dismiss": "閉じる" + "missing_path_dismiss": "閉じる", + "server_moving": "このサーバーは別のノードへ移動中です。移動が完了すると、ファイルは再び利用できるようになります。" }, "error": { "generic": "エラーが発生しました。もう一度お試しください。" @@ -8599,6 +8652,8 @@ "game_server_node": "ゲームサーバーノード", "select_game_server_node": "ゲームサーバーノードを選択", "game_server_node_description": "CS がインストール済みのゲームサーバーノードを選択してください", + "move_node_hint": "ノードを変更するには、サーバーを移動してください。ファイルもコピーされます。", + "move_node": "サーバーを移動", "type": "サーバータイプ", "select_type": "サーバータイプを選択", "game_mode": "ゲームモード", diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index 76ac6fd33..901c719c2 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -3752,6 +3752,58 @@ "browse": "플러그인 디렉터리", "saved": "플러그인을 저장했습니다. 서버를 재시작하는 중입니다.", "saved_disabled": "플러그인을 저장했습니다. 서버를 활성화하면 적용됩니다." + }, + "move": { + "menu": "다른 노드로 이동", + "title": "다른 노드로 이동", + "description": "{node}에서 서버가 중지되고, 파일이 새 노드로 복사된 뒤 그곳에서 다시 시작됩니다.", + "source_offline": "{node}이(가) 오프라인이라 서버 파일을 복사할 수 없습니다. 새 노드에서 기본 설정으로 시작되며, {node}에 남은 항목은 그대로 유지됩니다.", + "without_files": "서버 파일 없이 이동", + "target": "새 노드", + "no_nodes": "다른 게임 서버 노드가 없습니다", + "free_slots": "빈 슬롯: {count}", + "free_disk": "{size} 여유", + "confirm": "서버 이동", + "started": "{server}을(를) {node}(으)로 이동하는 중", + "warning": { + "address": "새 노드에서는 서버에 새 주소가 부여됩니다.", + "region": "서버가 {region}에 표시됩니다.", + "runtime": "새 노드는 {runtime}을(를) 실행하므로, 다른 프레임워크용으로 만든 플러그인은 로드되지 않습니다." + }, + "reason": { + "current": "현재 노드", + "disabled": "비활성화됨", + "offline": "오프라인", + "setup": "설정", + "unreachable": "연결할 수 없음", + "updating": "CS2 업데이트 중", + "no_build": "{game}이(가) 설치되어 있지 않음", + "no_slot": "빈 서버 슬롯 없음" + } + }, + "migration": { + "title": { + "active": "{node}(으)로 이동 중", + "Completed": "{node}(으)로 이동 완료", + "Failed": "{node}(으)로 이동 실패", + "Canceled": "{node}(으)로 이동 취소됨" + }, + "status": { + "Queued": "대기 중", + "Stopping": "중지 중", + "Transferring": "파일 복사 중", + "Finalizing": "전환 중", + "Completed": "완료", + "Failed": "실패", + "Canceled": "취소됨" + }, + "progress": "{done} / {total} ({percent}%)", + "without_files": "파일 없이", + "restored": "서버가 {node}(으)로 돌아왔습니다.", + "cancel": "이동 취소", + "cancel_requested": "이동 취소 중", + "dismiss": "닫기", + "locked": "서버를 다른 노드로 이동하는 중입니다. 이동이 끝난 후 다시 시도하세요." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "서버", "missing_path": "이 디렉터리는 아직 없습니다.", "missing_path_create": "만들기", - "missing_path_dismiss": "닫기" + "missing_path_dismiss": "닫기", + "server_moving": "이 서버는 다른 노드로 이동 중입니다. 이동이 끝나면 파일을 다시 사용할 수 있습니다." }, "error": { "generic": "오류가 발생했습니다. 다시 시도해 주세요." @@ -8599,6 +8652,8 @@ "game_server_node": "게임 서버 노드", "select_game_server_node": "게임 서버 노드 선택", "game_server_node_description": "CS가 설치된 게임 서버 노드를 선택하세요", + "move_node_hint": "노드를 변경하려면 서버를 이동하세요. 파일도 함께 복사됩니다.", + "move_node": "서버 이동", "type": "서버 유형", "select_type": "서버 유형 선택", "game_mode": "게임 모드", diff --git a/i18n/locales/pl_PL.json b/i18n/locales/pl_PL.json index c424d2a81..7d4fa96d0 100644 --- a/i18n/locales/pl_PL.json +++ b/i18n/locales/pl_PL.json @@ -3752,6 +3752,58 @@ "browse": "Katalog pluginów", "saved": "Zapisano pluginy. Serwer uruchamia się ponownie.", "saved_disabled": "Zapisano pluginy. Zadziałają po włączeniu serwera." + }, + "move": { + "menu": "Przenieś na inny węzeł", + "title": "Przenieś na inny węzeł", + "description": "Serwer zostanie zatrzymany na węźle {node}, jego pliki zostaną skopiowane na nowy węzeł, a następnie zostanie tam ponownie uruchomiony.", + "source_offline": "Węzeł {node} jest offline, więc nie można skopiować plików serwera. Serwer uruchomi się na nowym węźle z domyślnymi ustawieniami, a wszystko, co zostało na węźle {node}, tam pozostanie.", + "without_files": "Przenieś bez plików serwera", + "target": "Nowy węzeł", + "no_nodes": "Brak innych węzłów serwerów gry", + "free_slots": "Wolne sloty: {count}", + "free_disk": "{size} wolnego miejsca", + "confirm": "Przenieś serwer", + "started": "Przenoszenie {server} na węzeł {node}", + "warning": { + "address": "Na nowym węźle serwer otrzyma nowy adres.", + "region": "Serwer będzie widoczny w regionie {region}.", + "runtime": "Nowy węzeł działa na {runtime}, więc wtyczki zbudowane dla innego frameworka nie zostaną tam załadowane." + }, + "reason": { + "current": "Obecny węzeł", + "disabled": "Wyłączony", + "offline": "Offline", + "setup": "Konfiguracja", + "unreachable": "Nieosiągalny", + "updating": "Aktualizacja CS2", + "no_build": "Nie zainstalowano {game}", + "no_slot": "Brak wolnych slotów serwerów" + } + }, + "migration": { + "title": { + "active": "Przenoszenie na węzeł {node}", + "Completed": "Przeniesiono na węzeł {node}", + "Failed": "Przeniesienie na węzeł {node} nie powiodło się", + "Canceled": "Anulowano przeniesienie na węzeł {node}" + }, + "status": { + "Queued": "W kolejce", + "Stopping": "Zatrzymywanie", + "Transferring": "Kopiowanie plików", + "Finalizing": "Przełączanie", + "Completed": "Ukończono", + "Failed": "Niepowodzenie", + "Canceled": "Anulowano" + }, + "progress": "{done} z {total} ({percent}%)", + "without_files": "bez plików", + "restored": "Serwer wrócił na węzeł {node}.", + "cancel": "Anuluj przenoszenie", + "cancel_requested": "Anulowanie przenoszenia", + "dismiss": "Zamknij", + "locked": "Serwer jest przenoszony na inny węzeł. Spróbuj ponownie po zakończeniu przenoszenia." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Serwer", "missing_path": "Ten katalog jeszcze nie istnieje.", "missing_path_create": "Utwórz go", - "missing_path_dismiss": "Odrzuć" + "missing_path_dismiss": "Odrzuć", + "server_moving": "Ten serwer jest przenoszony na inny węzeł. Jego pliki będą ponownie dostępne po zakończeniu przenoszenia." }, "error": { "generic": "Wystąpił błąd. Spróbuj ponownie." @@ -8599,6 +8652,8 @@ "game_server_node": "Węzeł serwerów gry", "select_game_server_node": "Wybierz węzeł serwerów gry", "game_server_node_description": "Wybierz węzeł serwerów gry z zainstalowanym CS", + "move_node_hint": "Aby zmienić węzeł, przenieś serwer. Jego pliki zostaną skopiowane.", + "move_node": "Przenieś serwer", "type": "Typ serwera", "select_type": "Wybierz typ serwera", "game_mode": "Tryb gry", diff --git a/i18n/locales/pt_BR.json b/i18n/locales/pt_BR.json index d2ec6bb0b..dfa4759a9 100644 --- a/i18n/locales/pt_BR.json +++ b/i18n/locales/pt_BR.json @@ -3752,6 +3752,58 @@ "browse": "Diretório de plugins", "saved": "Plugins salvos. O servidor está reiniciando.", "saved_disabled": "Plugins salvos. Eles valem quando o servidor for ativado." + }, + "move": { + "menu": "Mover para Outro Nó", + "title": "Mover para Outro Nó", + "description": "O servidor é parado em {node}, seus arquivos são copiados para o novo nó e ele é iniciado novamente lá.", + "source_offline": "{node} está offline, então os arquivos do servidor não podem ser copiados. Ele será iniciado no novo nó com as configurações padrão, e tudo o que ficou em {node} permanece lá.", + "without_files": "Mover sem os arquivos do servidor", + "target": "Novo nó", + "no_nodes": "Não há outros nós de servidor de jogo", + "free_slots": "Slots livres: {count}", + "free_disk": "{size} livres", + "confirm": "Mover Servidor", + "started": "Movendo {server} para {node}", + "warning": { + "address": "O servidor receberá um novo endereço no novo nó.", + "region": "O servidor será listado em {region}.", + "runtime": "O novo nó executa {runtime}, então plugins feitos para o outro framework não serão carregados lá." + }, + "reason": { + "current": "Nó atual", + "disabled": "Desativado", + "offline": "Offline", + "setup": "Configuração", + "unreachable": "Inacessível", + "updating": "Atualizando CS2", + "no_build": "{game} não está instalado", + "no_slot": "Sem slots de servidor livres" + } + }, + "migration": { + "title": { + "active": "Movendo para {node}", + "Completed": "Movido para {node}", + "Failed": "Falha ao mover para {node}", + "Canceled": "Movimentação para {node} cancelada" + }, + "status": { + "Queued": "Na fila", + "Stopping": "Parando", + "Transferring": "Copiando Arquivos", + "Finalizing": "Alternando", + "Completed": "Concluído", + "Failed": "Falhou", + "Canceled": "Cancelado" + }, + "progress": "{done} de {total} ({percent}%)", + "without_files": "sem arquivos", + "restored": "O servidor está de volta em {node}.", + "cancel": "Cancelar Movimentação", + "cancel_requested": "Cancelando a movimentação", + "dismiss": "Dispensar", + "locked": "O servidor está sendo movido para outro nó. Tente novamente quando a mudança terminar." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Servidor", "missing_path": "Este diretório ainda não existe.", "missing_path_create": "Criar", - "missing_path_dismiss": "Dispensar" + "missing_path_dismiss": "Dispensar", + "server_moving": "Este servidor está sendo movido para outro nó. Seus arquivos estarão disponíveis novamente quando a movimentação terminar." }, "error": { "generic": "Ocorreu um erro. Por favor, tente novamente." @@ -8599,6 +8652,8 @@ "game_server_node": "Nó Servidor de Jogo", "select_game_server_node": "Selecionar nó do servidor de jogo", "game_server_node_description": "Selecione um nó de servidor de jogo com CS instalado", + "move_node_hint": "Para trocar o nó, mova o servidor. Seus arquivos são copiados junto.", + "move_node": "Mover Servidor", "type": "Tipo de Servidor", "select_type": "Selecionar tipo de servidor", "game_mode": "Modo de jogo", diff --git a/i18n/locales/ru_RU.json b/i18n/locales/ru_RU.json index c47ae4d37..1b7feb7d4 100644 --- a/i18n/locales/ru_RU.json +++ b/i18n/locales/ru_RU.json @@ -3752,6 +3752,58 @@ "browse": "Каталог плагинов", "saved": "Плагины сохранены. Сервер перезапускается.", "saved_disabled": "Плагины сохранены. Они применятся, когда сервер будет включён." + }, + "move": { + "menu": "Перенести на другую ноду", + "title": "Перенести на другую ноду", + "description": "Сервер останавливается на ноде {node}, его файлы копируются на новую ноду, и там он запускается снова.", + "source_offline": "Нода {node} не в сети, поэтому файлы сервера нельзя скопировать. Сервер запустится на новой ноде с настройками по умолчанию, а всё, что осталось на ноде {node}, там и останется.", + "without_files": "Перенести без файлов сервера", + "target": "Новая нода", + "no_nodes": "Других нод игровых серверов нет", + "free_slots": "Свободных слотов: {count}", + "free_disk": "Свободно {size}", + "confirm": "Перенести сервер", + "started": "Перенос {server} на ноду {node}", + "warning": { + "address": "На новой ноде сервер получит новый адрес.", + "region": "Сервер будет отображаться в регионе {region}.", + "runtime": "Новая нода работает на {runtime}, поэтому плагины, собранные для другого фреймворка, там не загрузятся." + }, + "reason": { + "current": "Текущая нода", + "disabled": "Отключена", + "offline": "Оффлайн", + "setup": "Настройка", + "unreachable": "Недоступна", + "updating": "Обновляет CS2", + "no_build": "{game} не установлен", + "no_slot": "Нет свободных слотов для серверов" + } + }, + "migration": { + "title": { + "active": "Перенос на ноду {node}", + "Completed": "Перенесён на ноду {node}", + "Failed": "Не удалось перенести на ноду {node}", + "Canceled": "Перенос на ноду {node} отменён" + }, + "status": { + "Queued": "В очереди", + "Stopping": "Остановка", + "Transferring": "Копирование файлов", + "Finalizing": "Переключение", + "Completed": "Завершено", + "Failed": "Ошибка", + "Canceled": "Отменено" + }, + "progress": "{done} из {total} ({percent}%)", + "without_files": "без файлов", + "restored": "Сервер снова на ноде {node}.", + "cancel": "Отменить перенос", + "cancel_requested": "Отмена переноса", + "dismiss": "Скрыть", + "locked": "Сервер переносится на другую ноду. Повторите попытку после завершения переноса." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Сервер", "missing_path": "Этого каталога пока не существует.", "missing_path_create": "Создать его", - "missing_path_dismiss": "Скрыть" + "missing_path_dismiss": "Скрыть", + "server_moving": "Этот сервер переносится на другую ноду. Его файлы снова станут доступны после завершения переноса." }, "error": { "generic": "Произошла ошибка. Попробуй ещё раз." @@ -8599,6 +8652,8 @@ "game_server_node": "Нода игровых серверов", "select_game_server_node": "Выбрать ноду игровых серверов", "game_server_node_description": "Выбери ноду игровых серверов с установленным CS", + "move_node_hint": "Чтобы сменить ноду, перенесите сервер. Его файлы будут скопированы.", + "move_node": "Перенести сервер", "type": "Тип сервера", "select_type": "Выбрать тип сервера", "game_mode": "Режим игры", diff --git a/i18n/locales/sv_SE.json b/i18n/locales/sv_SE.json index 11cbcd6ef..c2d8b9856 100644 --- a/i18n/locales/sv_SE.json +++ b/i18n/locales/sv_SE.json @@ -3752,6 +3752,58 @@ "browse": "Pluginkatalog", "saved": "Plugins sparade. Servern startas om.", "saved_disabled": "Plugins sparade. De gäller när servern är aktiverad." + }, + "move": { + "menu": "Flytta till en annan nod", + "title": "Flytta till en annan nod", + "description": "Servern stoppas på {node}, dess filer kopieras till den nya noden och den startas igen där.", + "source_offline": "{node} är offline, så serverns filer kan inte kopieras. Den startar på den nya noden med standardinställningar, och allt som finns kvar på {node} stannar där.", + "without_files": "Flytta utan serverns filer", + "target": "Ny nod", + "no_nodes": "Det finns inga andra spelservernoder", + "free_slots": "Lediga platser: {count}", + "free_disk": "{size} ledigt", + "confirm": "Flytta server", + "started": "Flyttar {server} till {node}", + "warning": { + "address": "Servern får en ny adress på den nya noden.", + "region": "Servern kommer att listas i {region}.", + "runtime": "Den nya noden kör {runtime}, så plugins byggda för det andra ramverket laddas inte där." + }, + "reason": { + "current": "Nuvarande nod", + "disabled": "Inaktiverad", + "offline": "Offline", + "setup": "Konfiguration", + "unreachable": "Går inte att nå", + "updating": "Uppdaterar CS2", + "no_build": "{game} är inte installerat", + "no_slot": "Inga lediga serverplatser" + } + }, + "migration": { + "title": { + "active": "Flyttar till {node}", + "Completed": "Flyttad till {node}", + "Failed": "Flytten till {node} misslyckades", + "Canceled": "Flytten till {node} avbröts" + }, + "status": { + "Queued": "Köad", + "Stopping": "Stoppar", + "Transferring": "Kopierar filer", + "Finalizing": "Växlar över", + "Completed": "Slutförd", + "Failed": "Misslyckades", + "Canceled": "Avbruten" + }, + "progress": "{done} av {total} ({percent}%)", + "without_files": "utan filer", + "restored": "Servern är tillbaka på {node}.", + "cancel": "Avbryt flytt", + "cancel_requested": "Avbryter flytten", + "dismiss": "Stäng", + "locked": "Servern flyttas till en annan nod. Försök igen när flytten är klar." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Server", "missing_path": "Den här mappen finns inte än.", "missing_path_create": "Skapa den", - "missing_path_dismiss": "Avfärda" + "missing_path_dismiss": "Avfärda", + "server_moving": "Den här servern flyttas till en annan nod. Dess filer blir tillgängliga igen när flytten är klar." }, "error": { "generic": "Ett fel uppstod. Försök igen." @@ -8599,6 +8652,8 @@ "game_server_node": "Spelservernod", "select_game_server_node": "Välj spelservernod", "game_server_node_description": "Välj en spelservernod med CS installerat", + "move_node_hint": "Flytta servern för att byta nod. Dess filer kopieras över.", + "move_node": "Flytta server", "type": "Servertyp", "select_type": "Välj servertyp", "game_mode": "Spelläge", diff --git a/i18n/locales/tr_TR.json b/i18n/locales/tr_TR.json index 1aa0fa03f..db0d617b6 100644 --- a/i18n/locales/tr_TR.json +++ b/i18n/locales/tr_TR.json @@ -3752,6 +3752,58 @@ "browse": "Eklenti Dizini", "saved": "Eklentiler kaydedildi. Sunucu yeniden başlatılıyor.", "saved_disabled": "Eklentiler kaydedildi. Sunucu etkinleştirildiğinde uygulanır." + }, + "move": { + "menu": "Başka Düğüme Taşı", + "title": "Başka Düğüme Taşı", + "description": "Sunucu {node} düğümünde durdurulur, dosyaları yeni düğüme kopyalanır ve orada yeniden başlatılır.", + "source_offline": "{node} düğümü çevrimdışı olduğu için sunucunun dosyaları kopyalanamıyor. Sunucu yeni düğümde varsayılan ayarlarla başlayacak ve {node} düğümünde kalan her şey orada kalacak.", + "without_files": "Sunucunun dosyaları olmadan taşı", + "target": "Yeni düğüm", + "no_nodes": "Başka oyun sunucusu düğümü yok", + "free_slots": "Boş slot: {count}", + "free_disk": "{size} boş", + "confirm": "Sunucuyu Taşı", + "started": "{server}, {node} düğümüne taşınıyor", + "warning": { + "address": "Sunucu yeni düğümde yeni bir adres alır.", + "region": "Sunucu {region} bölgesinde listelenecek.", + "runtime": "Yeni düğüm {runtime} çalıştırıyor, bu yüzden diğer çerçeve için derlenmiş eklentiler orada yüklenmez." + }, + "reason": { + "current": "Mevcut düğüm", + "disabled": "Devre dışı", + "offline": "Çevrimdışı", + "setup": "Kurulum", + "unreachable": "Erişilemiyor", + "updating": "CS2 güncelleniyor", + "no_build": "{game} yüklü değil", + "no_slot": "Boş sunucu slotu yok" + } + }, + "migration": { + "title": { + "active": "{node} düğümüne taşınıyor", + "Completed": "{node} düğümüne taşındı", + "Failed": "{node} düğümüne taşıma başarısız oldu", + "Canceled": "{node} düğümüne taşıma iptal edildi" + }, + "status": { + "Queued": "Sırada", + "Stopping": "Durduruluyor", + "Transferring": "Dosyalar Kopyalanıyor", + "Finalizing": "Geçiş Yapılıyor", + "Completed": "Tamamlandı", + "Failed": "Başarısız", + "Canceled": "İptal edildi" + }, + "progress": "{done} / {total} (%{percent})", + "without_files": "dosyalar olmadan", + "restored": "Sunucu yeniden {node} düğümünde.", + "cancel": "Taşımayı İptal Et", + "cancel_requested": "Taşıma iptal ediliyor", + "dismiss": "Kapat", + "locked": "Sunucu başka bir düğüme taşınıyor. Taşıma bittikten sonra tekrar dene." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Sunucu", "missing_path": "Bu dizin henüz yok.", "missing_path_create": "Oluştur", - "missing_path_dismiss": "Kapat" + "missing_path_dismiss": "Kapat", + "server_moving": "Bu sunucu başka bir düğüme taşınıyor. Taşıma bittiğinde dosyaları yeniden kullanılabilir olacak." }, "error": { "generic": "Bir hata oluştu. Tekrar dene." @@ -8599,6 +8652,8 @@ "game_server_node": "Sunucu Düğümü", "select_game_server_node": "Sunucu düğümü seç", "game_server_node_description": "CS yüklü bir sunucu düğümü seç", + "move_node_hint": "Düğümü değiştirmek için sunucuyu taşıyın. Dosyaları da kopyalanır.", + "move_node": "Sunucuyu Taşı", "type": "Sunucu Türü", "select_type": "Sunucu türü seç", "game_mode": "Oyun Modu", diff --git a/i18n/locales/uk_UA.json b/i18n/locales/uk_UA.json index b8a6bc436..5b1ee63c1 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -3752,6 +3752,58 @@ "browse": "Каталог плагінів", "saved": "Плагіни збережено. Сервер перезапускається.", "saved_disabled": "Плагіни збережено. Вони застосуються, коли сервер буде увімкнено." + }, + "move": { + "menu": "Перенести на іншу ноду", + "title": "Перенести на іншу ноду", + "description": "Сервер зупиняється на ноді {node}, його файли копіюються на нову ноду, і там він запускається знову.", + "source_offline": "Нода {node} не в мережі, тому файли сервера не можна скопіювати. Сервер запуститься на новій ноді з типовими налаштуваннями, а все, що залишилося на ноді {node}, там і залишиться.", + "without_files": "Перенести без файлів сервера", + "target": "Нова нода", + "no_nodes": "Інших нод ігрових серверів немає", + "free_slots": "Вільних слотів: {count}", + "free_disk": "Вільно {size}", + "confirm": "Перенести сервер", + "started": "Перенесення {server} на ноду {node}", + "warning": { + "address": "На новій ноді сервер отримає нову адресу.", + "region": "Сервер буде показано в регіоні {region}.", + "runtime": "Нова нода працює на {runtime}, тому плагіни, зібрані для іншого фреймворку, там не завантажаться." + }, + "reason": { + "current": "Поточна нода", + "disabled": "Вимкнено", + "offline": "Офлайн", + "setup": "Налаштування", + "unreachable": "Недоступна", + "updating": "Оновлює CS2", + "no_build": "{game} не встановлено", + "no_slot": "Немає вільних слотів для серверів" + } + }, + "migration": { + "title": { + "active": "Перенесення на ноду {node}", + "Completed": "Перенесено на ноду {node}", + "Failed": "Не вдалося перенести на ноду {node}", + "Canceled": "Перенесення на ноду {node} скасовано" + }, + "status": { + "Queued": "У черзі", + "Stopping": "Зупинка", + "Transferring": "Копіювання файлів", + "Finalizing": "Перемикання", + "Completed": "Завершено", + "Failed": "Помилка", + "Canceled": "Скасовано" + }, + "progress": "{done} з {total} ({percent}%)", + "without_files": "без файлів", + "restored": "Сервер знову на ноді {node}.", + "cancel": "Скасувати перенесення", + "cancel_requested": "Скасування перенесення", + "dismiss": "Закрити", + "locked": "Сервер переноситься на іншу ноду. Спробуйте ще раз після завершення перенесення." } } }, @@ -5875,7 +5927,8 @@ "scope_server": "Сервер", "missing_path": "Цієї директорії ще не існує.", "missing_path_create": "Створити її", - "missing_path_dismiss": "Приховати" + "missing_path_dismiss": "Приховати", + "server_moving": "Цей сервер переноситься на іншу ноду. Його файли знову стануть доступні після завершення перенесення." }, "error": { "generic": "Сталася помилка. Спробуй ще раз." @@ -8599,6 +8652,8 @@ "game_server_node": "Нода ігрових серверів", "select_game_server_node": "Обери ноду ігрових серверів", "game_server_node_description": "Обери ноду ігрових серверів зі встановленим CS", + "move_node_hint": "Щоб змінити ноду, перенесіть сервер. Його файли буде скопійовано.", + "move_node": "Перенести сервер", "type": "Тип сервера", "select_type": "Обери тип сервера", "game_mode": "Режим гри", diff --git a/i18n/locales/zh_Hans.json b/i18n/locales/zh_Hans.json index 1fd2bf0c5..1d8aa3e85 100644 --- a/i18n/locales/zh_Hans.json +++ b/i18n/locales/zh_Hans.json @@ -3752,6 +3752,58 @@ "browse": "插件目录", "saved": "插件已保存,服务器正在重启。", "saved_disabled": "插件已保存,将在服务器启用后生效。" + }, + "move": { + "menu": "迁移到其他节点", + "title": "迁移到其他节点", + "description": "服务器会在 {node} 上停止,其文件将复制到新节点,然后在新节点上重新启动。", + "source_offline": "{node} 已离线,因此无法复制服务器文件。服务器将以默认设置在新节点上启动,留在 {node} 上的内容将保留在原处。", + "without_files": "迁移时不带服务器文件", + "target": "新节点", + "no_nodes": "没有其他游戏服务器节点", + "free_slots": "空闲槽位:{count}", + "free_disk": "可用 {size}", + "confirm": "迁移服务器", + "started": "正在将 {server} 迁移到 {node}", + "warning": { + "address": "服务器在新节点上会获得新的地址。", + "region": "服务器将显示在 {region} 地区。", + "runtime": "新节点运行 {runtime},因此为另一框架构建的插件将无法在其上加载。" + }, + "reason": { + "current": "当前节点", + "disabled": "已禁用", + "offline": "离线", + "setup": "设置", + "unreachable": "无法连接", + "updating": "正在更新 CS2", + "no_build": "未安装 {game}", + "no_slot": "没有空闲的服务器槽位" + } + }, + "migration": { + "title": { + "active": "正在迁移到 {node}", + "Completed": "已迁移到 {node}", + "Failed": "迁移到 {node} 失败", + "Canceled": "已取消迁移到 {node}" + }, + "status": { + "Queued": "排队中", + "Stopping": "正在停止", + "Transferring": "正在复制文件", + "Finalizing": "正在切换", + "Completed": "已完成", + "Failed": "失败", + "Canceled": "已取消" + }, + "progress": "{done} / {total} ({percent}%)", + "without_files": "不含文件", + "restored": "服务器已回到 {node}。", + "cancel": "取消迁移", + "cancel_requested": "正在取消迁移", + "dismiss": "关闭", + "locked": "服务器正在迁移到另一个节点。迁移完成后请重试。" } } }, @@ -5875,7 +5927,8 @@ "scope_server": "服务器", "missing_path": "该目录尚不存在。", "missing_path_create": "创建它", - "missing_path_dismiss": "忽略" + "missing_path_dismiss": "忽略", + "server_moving": "此服务器正在迁移到其他节点。迁移完成后,其文件将重新可用。" }, "error": { "generic": "发生错误。请重试。" @@ -8599,6 +8652,8 @@ "game_server_node": "游戏服务器节点", "select_game_server_node": "选择游戏服务器节点", "game_server_node_description": "选择已安装 CS 的游戏服务器节点", + "move_node_hint": "如需更换节点,请迁移服务器。其文件会一并复制。", + "move_node": "迁移服务器", "type": "服务器类型", "select_type": "选择服务器类型", "game_mode": "游戏模式", diff --git a/i18n/locales/zh_Hant.json b/i18n/locales/zh_Hant.json index e4346f83c..e708b0cfd 100644 --- a/i18n/locales/zh_Hant.json +++ b/i18n/locales/zh_Hant.json @@ -3752,6 +3752,58 @@ "browse": "外掛目錄", "saved": "外掛已儲存,伺服器正在重新啟動。", "saved_disabled": "外掛已儲存,將在伺服器啟用後生效。" + }, + "move": { + "menu": "遷移至其他節點", + "title": "遷移至其他節點", + "description": "伺服器會在 {node} 上停止,其檔案將複製到新節點,然後在新節點上重新啟動。", + "source_offline": "{node} 已離線,因此無法複製伺服器檔案。伺服器將以預設設定在新節點上啟動,留在 {node} 上的內容將保留在原處。", + "without_files": "遷移時不帶伺服器檔案", + "target": "新節點", + "no_nodes": "沒有其他遊戲伺服器節點", + "free_slots": "可用名額:{count}", + "free_disk": "可用 {size}", + "confirm": "遷移伺服器", + "started": "正在將 {server} 遷移至 {node}", + "warning": { + "address": "伺服器在新節點上會取得新的位址。", + "region": "伺服器將列於 {region} 地區。", + "runtime": "新節點執行 {runtime},因此為另一框架建置的外掛將無法在其上載入。" + }, + "reason": { + "current": "目前節點", + "disabled": "已停用", + "offline": "離線", + "setup": "設定", + "unreachable": "無法連線", + "updating": "正在更新 CS2", + "no_build": "未安裝 {game}", + "no_slot": "沒有可用的伺服器名額" + } + }, + "migration": { + "title": { + "active": "正在遷移至 {node}", + "Completed": "已遷移至 {node}", + "Failed": "遷移至 {node} 失敗", + "Canceled": "已取消遷移至 {node}" + }, + "status": { + "Queued": "排隊中", + "Stopping": "正在停止", + "Transferring": "正在複製檔案", + "Finalizing": "正在切換", + "Completed": "已完成", + "Failed": "失敗", + "Canceled": "已取消" + }, + "progress": "{done} / {total} ({percent}%)", + "without_files": "不含檔案", + "restored": "伺服器已回到 {node}。", + "cancel": "取消遷移", + "cancel_requested": "正在取消遷移", + "dismiss": "關閉", + "locked": "伺服器正在遷移到另一個節點。遷移完成後請重試。" } } }, @@ -5875,7 +5927,8 @@ "scope_server": "伺服器", "missing_path": "這個目錄尚不存在。", "missing_path_create": "建立它", - "missing_path_dismiss": "忽略" + "missing_path_dismiss": "忽略", + "server_moving": "此伺服器正在遷移至其他節點。遷移完成後,其檔案將重新可用。" }, "error": { "generic": "發生錯誤。請重試。" @@ -8599,6 +8652,8 @@ "game_server_node": "遊戲伺服器節點", "select_game_server_node": "選擇遊戲伺服器節點", "game_server_node_description": "選擇已安裝 CS 的遊戲伺服器節點", + "move_node_hint": "如需變更節點,請遷移伺服器。其檔案會一併複製。", + "move_node": "遷移伺服器", "type": "伺服器類型", "select_type": "選擇伺服器類型", "game_mode": "遊戲模式", diff --git a/pages/dedicated-servers/[id].vue b/pages/dedicated-servers/[id].vue index 55e211798..ee20226ba 100644 --- a/pages/dedicated-servers/[id].vue +++ b/pages/dedicated-servers/[id].vue @@ -2,7 +2,13 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { MoreVertical, Trash2, FolderOpen, Pencil } from "lucide-vue-next"; +import { + MoreVertical, + Trash2, + FolderOpen, + Pencil, + ArrowRightLeft, +} from "lucide-vue-next"; import { DropdownMenu, DropdownMenuContent, @@ -35,11 +41,16 @@ import { } from "~/components/ui/tooltip"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { ref, computed } from "vue"; +import { useNow } from "@vueuse/core"; import ServerForm from "~/components/servers/ServerForm.vue"; import RconCommander from "~/components/servers/RconCommander.vue"; import ServerPlayerManagement from "~/components/servers/ServerPlayerManagement.vue"; import ServerMapRotation from "~/components/servers/ServerMapRotation.vue"; import ServerPlugins from "~/components/servers/ServerPlugins.vue"; +import ServerMoveDialog from "~/components/servers/ServerMoveDialog.vue"; +import ServerMigrationPanel from "~/components/servers/ServerMigrationPanel.vue"; +import { useServerMigration } from "~/composables/useServerMigration"; +import { ACTIVE_SERVER_MIGRATION_STATUSES } from "~/types/serverMigration"; import { Eye, EyeOff } from "lucide-vue-next"; import Clipboard from "~/components/ClipBoard.vue"; import ServerStatus from "~/components/servers/ServerStatus.vue"; @@ -60,6 +71,46 @@ const isManager = computed(() => const isAdmin = computed(() => authStore.isAdmin); const serverMenu = ref(false); +const moveDialog = ref(false); +const dismissedMigrationId = ref(null); + +const route = useRoute(); +const { + server: migratingServer, + node: serverNode, + migration, + isMigrating, + sourceReachable, +} = useServerMigration( + computed(() => route.params.id as string), + isAdmin, +); + +const MIGRATION_SHOWN_FOR_MS = 15 * 60 * 1000; +const now = useNow({ interval: 30_000 }); + +const visibleMigration = computed(() => { + const latest = migration.value; + + if (!latest) { + return null; + } + + if (ACTIVE_SERVER_MIGRATION_STATUSES.includes(latest.status)) { + return latest; + } + + if ( + latest.id === dismissedMigrationId.value || + !latest.finished_at || + now.value.getTime() - new Date(latest.finished_at).getTime() > + MIGRATION_SHOWN_FOR_MS + ) { + return null; + } + + return latest; +}); const heroClasses = "relative min-w-0 max-w-full px-6 pt-5 pb-6 max-sm:p-4 border border-border [background:linear-gradient(180deg,hsl(var(--card)/0.2)_0%,hsl(var(--card)/0.04)_100%)]"; @@ -94,6 +145,9 @@ const titleClasses = {{ server.region }} {{ server.type }} + + {{ serverNode.label || serverNode.id }} +
- +
+ + + + {{ $t("common.actions.edit") }}
- +
+ + import { $ } from "~/generated/zeus"; +import { toast } from "@/components/ui/toast"; import { generateMutation, generateQuery, @@ -573,7 +675,7 @@ export default { }); }, async deleteServer() { - await this.$apollo.mutate({ + const { data } = await this.$apollo.mutate({ mutation: generateMutation({ delete_servers_by_pk: [ { @@ -586,6 +688,17 @@ export default { }), }); + if (!data?.delete_servers_by_pk) { + toast({ + variant: "destructive", + title: this.$t("common.error"), + description: this.$t( + "pages.dedicated_servers.detail.migration.locked", + ), + }); + return; + } + this.$router.push("/dedicated-servers"); }, }, diff --git a/pages/dedicated-servers/[serverId]/files.vue b/pages/dedicated-servers/[serverId]/files.vue index d1304c704..60ac72a17 100644 --- a/pages/dedicated-servers/[serverId]/files.vue +++ b/pages/dedicated-servers/[serverId]/files.vue @@ -17,9 +17,16 @@
+

+ {{ $t("file_manager.server_moving") }} +

@@ -40,6 +47,7 @@ import PageTransition from "~/components/ui/transitions/PageTransition.vue"; import { generateQuery } from "~/graphql/graphqlGen"; import getGraphqlClient from "~/graphql/getGraphqlClient"; import FileManagerContainer from "~/components/file-manager/FileManagerContainer.vue"; +import { useServerMigration } from "~/composables/useServerMigration"; definePageMeta({ middleware: "admin" }); @@ -57,6 +65,17 @@ const server = ref<{ }; } | null>(null); +const { server: migratingServer, isMigrating } = useServerMigration( + serverId, + computed(() => useAuthStore().isAdmin), +); + +const nodeId = computed( + () => + migratingServer.value?.game_server_node_id ?? + server.value?.game_server_node_id, +); + onMounted(async () => { // Check if user is administrator const authStore = useAuthStore(); diff --git a/pages/file-popout/[scope]/[id].vue b/pages/file-popout/[scope]/[id].vue index 97b4bf2c4..6791612f8 100644 --- a/pages/file-popout/[scope]/[id].vue +++ b/pages/file-popout/[scope]/[id].vue @@ -33,9 +33,16 @@
+

+ {{ $t("file_manager.server_moving") }} +

label.value || id.value); +const { server: migratingServer, isMigrating } = useServerMigration( + computed(() => (scope.value === "server" ? id.value : null)), + computed(() => useAuthStore().isAdmin), +); + +const currentNodeId = computed( + () => + (scope.value === "server" && migratingServer.value?.game_server_node_id) || + nodeId.value, +); + useHead({ title: computed(() => label.value ? `${label.value} — Files` : "Files", diff --git a/tests/components/servers/ServerMove.spec.ts b/tests/components/servers/ServerMove.spec.ts new file mode 100644 index 000000000..cf2806cc7 --- /dev/null +++ b/tests/components/servers/ServerMove.spec.ts @@ -0,0 +1,327 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import ServerMoveDialog from "~/components/servers/ServerMoveDialog.vue"; +import ServerMigrationPanel from "~/components/servers/ServerMigrationPanel.vue"; +import type { + MigratingServer, + ServerMigrationRow, + ServerMoveNode, +} from "~/types/serverMigration"; + +const { toast, moveNodes } = vi.hoisted(() => ({ + toast: vi.fn(), + moveNodes: { value: [] as Array, loaded: true }, +})); + +vi.mock("@/components/ui/toast", async (importOriginal) => ({ + ...(await importOriginal()), + toast, +})); + +vi.mock("~/composables/useServerMigration", async () => { + const { ref } = await import("vue"); + return { + useServerMoveNodes: () => ({ + nodes: ref(moveNodes.value), + loaded: ref(moveNodes.loaded), + }), + }; +}); + +const node = (overrides: Partial): ServerMoveNode => ({ + id: "node-b", + label: "Node B", + status: "Online", + node_ip: "100.64.0.2", + enabled: true, + build_id: 100, + csgo_build_id: null, + update_status: null, + region: "EU", + e_region: { description: "Europe" }, + disk_available_gb: 100, + available_dedicated_slot_count: 2, + pin_plugin_runtime: null, + ...overrides, +}); + +const server: MigratingServer = { + id: "server-1", + game: "cs2", + region: "EU", + plugin_runtime: "swiftlys2", + game_server_node_id: "node-a", + game_server_node: { + id: "node-a", + label: "Node A", + status: "Online", + node_ip: "100.64.0.1", + offline_at: null, + pin_plugin_runtime: null, + }, + migrations: [], +}; + +let mutate: ReturnType; +let unmount: (() => void) | null = null; + +afterEach(() => { + moveNodes.loaded = true; + unmount?.(); + unmount = null; + vi.restoreAllMocks(); + toast.mockReset(); +}); + +async function mount(component: any, props: Record) { + const client = (useNuxtApp() as any).$apollo.defaultClient; + mutate = vi.fn().mockResolvedValue({ data: {} }); + vi.spyOn(client, "mutate").mockImplementation(mutate); + + const wrapper = await mountSuspended(component, { + props, + attachTo: document.body, + global: { + mixins: (useNuxtApp().vueApp as any)._context.mixins, + }, + }); + unmount = () => wrapper.unmount(); + await flushPromises(); + return wrapper; +} + +const radios = () => + Array.from( + document.body.querySelectorAll('[role="radio"]'), + ); + +const buttonNamed = (name: string) => + Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === name, + ); + +describe("ServerMoveDialog", () => { + it("greys out the nodes a server cannot move to and says why", async () => { + moveNodes.value = [ + node({ id: "node-a", label: "Node A" }), + node({ id: "node-b", label: "Node B" }), + node({ id: "node-c", label: "Node C", available_dedicated_slot_count: 0 }), + ]; + + await mount(ServerMoveDialog, { + open: true, + server, + serverLabel: "Community", + sourceReachable: true, + }); + + const [first, ...rest] = radios(); + expect(first.textContent).toContain("Node B"); + expect(first.disabled).toBe(false); + expect( + rest.map((radio) => [radio.textContent?.trim(), radio.disabled]), + ).toEqual([ + [expect.stringContaining("Current node"), true], + [expect.stringContaining("No free server slots"), true], + ]); + }); + + it("names why an unreachable node cannot take the server", async () => { + moveNodes.value = [ + node({ id: "node-b", label: "Node B", node_ip: null }), + node({ id: "node-c", label: "Node C", status: "Offline" }), + ]; + + await mount(ServerMoveDialog, { + open: true, + server, + serverLabel: "Community", + sourceReachable: true, + }); + + expect(radios().map((radio) => radio.textContent?.trim())).toEqual([ + expect.stringContaining("Unreachable"), + expect.stringContaining("Offline"), + ]); + }); + + it("says it is loading rather than that there are no nodes", async () => { + moveNodes.value = []; + moveNodes.loaded = false; + + await mount(ServerMoveDialog, { + open: true, + server, + serverLabel: "Community", + sourceReachable: true, + }); + + expect(document.body.textContent).toContain("Loading..."); + expect(document.body.textContent).not.toContain( + "There are no other game server nodes", + ); + }); + + it("moves the server to the chosen node with its files", async () => { + moveNodes.value = [node({ id: "node-b" })]; + + const wrapper = await mount(ServerMoveDialog, { + open: true, + server, + serverLabel: "Community", + sourceReachable: true, + }); + + expect(buttonNamed("Move Server")!.disabled).toBe(true); + + radios()[0].click(); + await flushPromises(); + buttonNamed("Move Server")!.click(); + await flushPromises(); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0].variables).toEqual({ + server_id: "server-1", + game_server_node_id: "node-b", + without_files: false, + }); + expect(wrapper.emitted("update:open")).toEqual([[false]]); + }); + + it("only moves off an offline node once told to leave the files behind", async () => { + moveNodes.value = [node({ id: "node-b" })]; + + await mount(ServerMoveDialog, { + open: true, + server, + serverLabel: "Community", + sourceReachable: false, + }); + + radios()[0].click(); + await flushPromises(); + expect(buttonNamed("Move Server")!.disabled).toBe(true); + + document.body.querySelector('[role="checkbox"]')!.click(); + await flushPromises(); + buttonNamed("Move Server")!.click(); + await flushPromises(); + + expect(mutate.mock.calls[0][0].variables).toMatchObject({ + without_files: true, + }); + }); + + it("keeps the dialog open and reports why the api refused", async () => { + moveNodes.value = [node({ id: "node-b" })]; + + const wrapper = await mount(ServerMoveDialog, { + open: true, + server, + serverLabel: "Community", + sourceReachable: true, + }); + mutate.mockRejectedValue({ + graphQLErrors: [{ message: "Node B has no free server slots" }], + }); + + radios()[0].click(); + await flushPromises(); + buttonNamed("Move Server")!.click(); + await flushPromises(); + + expect(wrapper.emitted("update:open")).toBeUndefined(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + description: "Node B has no free server slots", + }), + ); + }); +}); + +describe("ServerMigrationPanel", () => { + const migration = ( + overrides: Partial, + ): ServerMigrationRow => ({ + id: "migration-1", + status: "Transferring", + with_files: true, + bytes_total: 1000, + bytes_done: 500, + error: null, + warnings: [], + created_at: new Date().toISOString(), + started_at: new Date().toISOString(), + finished_at: null, + from_game_server_node: { id: "node-a", label: "Node A" }, + to_game_server_node: { id: "node-b", label: "Node B" }, + ...overrides, + }); + + it("shows how much has been copied and lets the move be canceled", async () => { + const wrapper = await mount(ServerMigrationPanel, { + serverId: "server-1", + migration: migration({}), + }); + + expect(wrapper.text()).toContain("Moving to Node B"); + expect(wrapper.text()).toContain("(50%)"); + + buttonNamed("Cancel Move")!.click(); + await flushPromises(); + + expect(mutate.mock.calls[0][0].variables).toEqual({ + server_id: "server-1", + }); + }); + + it("shows no copy progress for a server with no files", async () => { + const wrapper = await mount(ServerMigrationPanel, { + serverId: "server-1", + migration: migration({ bytes_total: "0", bytes_done: "0" }), + }); + + expect(wrapper.text()).not.toContain("0 B of 0 B"); + }); + + it("reads the byte counts Hasura sends as strings", async () => { + const wrapper = await mount(ServerMigrationPanel, { + serverId: "server-1", + migration: migration({ bytes_total: "1000", bytes_done: "250" }), + }); + + expect(wrapper.text()).toContain("(25%)"); + }); + + it("never claims the copy is done before the server has switched", async () => { + const wrapper = await mount(ServerMigrationPanel, { + serverId: "server-1", + migration: migration({ bytes_done: 1200 }), + }); + + expect(wrapper.text()).toContain("(99%)"); + }); + + it("explains a failed move and that the server is back where it was", async () => { + const wrapper = await mount(ServerMigrationPanel, { + serverId: "server-1", + migration: migration({ + status: "Failed", + error: "The transfer stalled", + finished_at: new Date().toISOString(), + }), + }); + + expect(wrapper.text()).toContain("Move to Node B failed"); + expect(wrapper.text()).toContain("The transfer stalled"); + expect(wrapper.text()).toContain("The server is back on Node A."); + expect(buttonNamed("Cancel Move")).toBeUndefined(); + + document.body + .querySelector('button[aria-label="Dismiss"]')! + .click(); + expect(wrapper.emitted("dismiss")).toHaveLength(1); + }); +}); diff --git a/tests/types/serverMigration.spec.ts b/tests/types/serverMigration.spec.ts new file mode 100644 index 000000000..ac2380549 --- /dev/null +++ b/tests/types/serverMigration.spec.ts @@ -0,0 +1,76 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + formatBytes, + isNodeUp, + moveTargetIneligibility, + type ServerMoveNode, +} from "~/types/serverMigration"; + +const node = (overrides: Partial = {}): ServerMoveNode => ({ + id: "node-b", + label: "Node B", + status: "Online", + node_ip: "100.64.0.2", + enabled: true, + build_id: 100, + csgo_build_id: null, + update_status: null, + region: "EU", + disk_available_gb: 100, + available_dedicated_slot_count: 2, + pin_plugin_runtime: null, + ...overrides, +}); + +const cs2 = { game: "cs2", game_server_node_id: "node-a" }; + +describe("moveTargetIneligibility", () => { + it.each([ + ["the current node", node({ id: "node-a" }), cs2, "current"], + ["a disabled node", node({ enabled: false }), cs2, "disabled"], + ["an offline node", node({ status: "Offline" }), cs2, "offline"], + ["a node still being set up", node({ status: "Setup" }), cs2, "setup"], + [ + "an online node without an address", + node({ node_ip: null }), + cs2, + "unreachable", + ], + ["a node updating CS2", node({ update_status: "Downloading" }), cs2, "updating"], + ["a node without CS2", node({ build_id: null }), cs2, "no_build"], + [ + "a node without CS:GO for a CS:GO server", + node(), + { game: "csgo", game_server_node_id: "node-a" }, + "no_build", + ], + ["a full node", node({ available_dedicated_slot_count: 0 }), cs2, "no_slot"], + ])("rules out %s", (_label, candidate, server, key) => { + expect(moveTargetIneligibility(candidate, server)?.key).toBe(key); + }); + + it("accepts a node that is not taking new matches", () => { + expect( + moveTargetIneligibility(node({ status: "NotAcceptingNewMatches" }), cs2), + ).toBeNull(); + }); +}); + +describe("isNodeUp", () => { + it("needs both a live status and an address", () => { + expect(isNodeUp(node())).toBe(true); + expect(isNodeUp(node({ node_ip: null }))).toBe(false); + expect(isNodeUp(node({ status: "Setup" }))).toBe(false); + expect(isNodeUp(null)).toBe(false); + }); +}); + +describe("formatBytes", () => { + it("scales to the largest whole unit", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(3 * 1024 ** 3)).toBe("3.0 GB"); + expect(formatBytes(null)).toBe("0 B"); + }); +}); diff --git a/types/serverMigration.ts b/types/serverMigration.ts new file mode 100644 index 000000000..3195b30d6 --- /dev/null +++ b/types/serverMigration.ts @@ -0,0 +1,127 @@ +// Mirrors graphql/serverMigrationGraphql.ts and the api's +// DedicatedServerMigrationService -- extend them together. + +export const ACTIVE_SERVER_MIGRATION_STATUSES = [ + "Queued", + "Stopping", + "Transferring", + "Finalizing", +]; + +export const CANCELABLE_SERVER_MIGRATION_STATUSES = [ + "Queued", + "Stopping", + "Transferring", +]; + +export type ServerMoveNode = { + id: string; + label: string | null; + status: string | null; + node_ip: string | null; + enabled: boolean | null; + build_id: number | null; + csgo_build_id: number | null; + update_status: string | null; + region: string | null; + e_region?: { description: string | null } | null; + disk_available_gb: number | null; + available_dedicated_slot_count: number | null; + pin_plugin_runtime: string | null; +}; + +export type ServerMigrationRow = { + id: string; + status: string; + with_files: boolean; + bytes_total: number | string | null; + bytes_done: number | string; + error: string | null; + warnings: Array | null; + created_at: string; + started_at: string | null; + finished_at: string | null; + from_game_server_node: { id: string; label: string | null } | null; + to_game_server_node: { id: string; label: string | null } | null; +}; + +export type MigratingServer = { + id: string; + game: string; + region: string | null; + plugin_runtime: string | null; + game_server_node_id: string | null; + game_server_node: { + id: string; + label: string | null; + status: string | null; + node_ip: string | null; + offline_at: string | null; + pin_plugin_runtime: string | null; + } | null; + migrations: Array; +}; + +export type ServerMoveIneligibility = + | { key: "current" } + | { key: "disabled" } + | { key: "offline" } + | { key: "setup" } + | { key: "unreachable" } + | { key: "updating" } + | { key: "no_build"; game: string } + | { key: "no_slot" }; + +export function isNodeUp( + node: Pick | null | undefined, +): boolean { + return ( + !!node?.node_ip && + (node.status === "Online" || node.status === "NotAcceptingNewMatches") + ); +} + +export function moveTargetIneligibility( + node: ServerMoveNode, + server: Pick, +): ServerMoveIneligibility | null { + if (node.id === server.game_server_node_id) { + return { key: "current" }; + } + if (!node.enabled) { + return { key: "disabled" }; + } + if (!isNodeUp(node)) { + if (node.status === "Online" || node.status === "NotAcceptingNewMatches") { + return { key: "unreachable" }; + } + + return { key: node.status === "Setup" ? "setup" : "offline" }; + } + if (node.update_status) { + return { key: "updating" }; + } + if (server.game === "csgo" ? !node.csgo_build_id : !node.build_id) { + return { key: "no_build", game: server.game === "csgo" ? "CS:GO" : "CS2" }; + } + if (!node.available_dedicated_slot_count) { + return { key: "no_slot" }; + } + return null; +} + +export function formatBytes( + bytes: number | string | null | undefined, +): string { + const value = Number(bytes ?? 0); + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = value; + let unit = 0; + + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + + return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +}