diff --git a/agent/app/dto/response/nginx.go b/agent/app/dto/response/nginx.go index 73468c339..649259eb3 100644 --- a/agent/app/dto/response/nginx.go +++ b/agent/app/dto/response/nginx.go @@ -20,11 +20,15 @@ type NginxParam struct { // NginxBrotliRes carries the brotli settings together with where they live. // ManagedExternally is true when the user defined brotli by hand, in which // case the panel only reports the values and must not write its own copy. -// ManagedUnavailable is true when the panel could not wire the managed +// ManagedPending is true when nginx does not load the managed configuration +// yet but saving will wire it in, so the reported values are not in effect +// until then. +// ManagedUnavailable is true when the panel cannot wire the managed // configuration into nginx.conf at all, so the reported values are inert. type NginxBrotliRes struct { Params []NginxParam `json:"params"` ManagedExternally bool `json:"managedExternally"` + ManagedPending bool `json:"managedPending"` ManagedUnavailable bool `json:"managedUnavailable"` } diff --git a/agent/app/service/nginx_http_config.go b/agent/app/service/nginx_http_config.go index f6474f9f7..6413bbc1a 100644 --- a/agent/app/service/nginx_http_config.go +++ b/agent/app/service/nginx_http_config.go @@ -45,6 +45,10 @@ var ( // nginxHTTPBlockStartRe locates the http block opening, the fallback // insertion point, and captures its indentation. nginxHTTPBlockStartRe = regexp.MustCompile(`(?m)^([ \t]*)http[ \t]*\{[ \t]*\r?$`) + + // errNginxHTTPIncludeNoInsertionPoint tells a config the panel cannot edit + // safely apart from an I/O failure while editing it. + errNginxHTTPIncludeNoInsertionPoint = errors.New("no insertion point for the http.d include in nginx.conf") ) // nginxHTTPIncludePresent reports whether nginx.conf already loads http.d. @@ -96,7 +100,19 @@ func insertNginxHTTPInclude(content string) (string, error) { indent := content[m[2]:m[3]] + " " return content[:m[1]] + eol + indent + nginxHTTPIncludeDirective + content[m[1]:], nil } - return "", errors.New("no insertion point for the http.d include in nginx.conf") + return "", errNginxHTTPIncludeNoInsertionPoint +} + +// nginxHTTPIncludeInsertable reports whether nginx.conf already loads http.d +// or the include can be added automatically. Nothing is written: the +// insertion is computed in memory and discarded. +func nginxHTTPIncludeInsertable(install model.AppInstall) bool { + content, err := os.ReadFile(nginxMainConfigPath(install)) + if err != nil { + return false + } + _, err = insertNginxHTTPInclude(string(content)) + return err == nil } // ensureNginxHTTPIncludeActive makes nginx.conf load http.d, inserting the diff --git a/agent/app/service/nginx_http_config_test.go b/agent/app/service/nginx_http_config_test.go index 1071b4dfc..5726048cd 100644 --- a/agent/app/service/nginx_http_config_test.go +++ b/agent/app/service/nginx_http_config_test.go @@ -1,6 +1,7 @@ package service import ( + "errors" "strings" "testing" ) @@ -83,9 +84,15 @@ func TestInsertNginxHTTPIncludeFallsBackToHTTPBlock(t *testing.T) { } func TestInsertNginxHTTPIncludeRejectsConfigWithoutHTTPBlock(t *testing.T) { - if _, err := insertNginxHTTPInclude("events {}\n"); err == nil { + _, err := insertNginxHTTPInclude("events {}\n") + if err == nil { t.Fatal("a config without an http block must be rejected so callers can degrade") } + // The save path maps this error to an actionable message and must be able + // to tell it apart from an I/O failure. + if !errors.Is(err, errNginxHTTPIncludeNoInsertionPoint) { + t.Fatalf("unexpected error: %v", err) + } } func TestInsertNginxHTTPIncludeIgnoresCommentedIncludes(t *testing.T) { diff --git a/agent/app/service/nginx_module_runtime.go b/agent/app/service/nginx_module_runtime.go index ad4ff6957..c001ba4f9 100644 --- a/agent/app/service/nginx_module_runtime.go +++ b/agent/app/service/nginx_module_runtime.go @@ -224,12 +224,9 @@ func getNginxBrotliParams() (*response.NginxBrotliRes, error) { fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName) current = readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName)) } - res := &response.NginxBrotliRes{ - ManagedExternally: managedExternally, - // Without the include, values the panel would write would never reach - // nginx, so they are reported as unavailable rather than shown as if - // they were in effect. - ManagedUnavailable: !managedExternally && !nginxHTTPIncludePresent(install), + res := &response.NginxBrotliRes{ManagedExternally: managedExternally} + if !managedExternally { + res.ManagedPending, res.ManagedUnavailable = nginxBrotliManagedState(install) } for _, directive := range mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], current) { res.Params = append(res.Params, response.NginxParam{Name: directive.Name, Params: directive.Params}) @@ -237,6 +234,28 @@ func getNginxBrotliParams() (*response.NginxBrotliRes, error) { return res, nil } +// nginxBrotliManagedState reports whether the managed brotli file is missing +// from what nginx loads, and if so whether saving from the settings page can +// fix that. Either way the reported values are not in effect, so they must +// not be presented as if they were. +// +// A missing include is the common case for installs that enabled brotli +// before the panel managed its directives, and the save path inserts it. Only +// a config without a safe insertion point is reported as unavailable. +func nginxBrotliManagedState(install model.AppInstall) (pending, unavailable bool) { + if !nginxHTTPIncludePresent(install) { + if nginxHTTPIncludeInsertable(install) { + return true, false + } + return false, true + } + fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName) + if _, err := os.Stat(path.Join(nginxHTTPConfigDir(install), fileName)); os.IsNotExist(err) { + return true, false + } + return false, false +} + // readNginxUserBrotliDirectives collects the brotli directives the user wrote // in any of the files nginx loads them from. func readNginxUserBrotliDirectives(install model.AppInstall) map[string][]string { @@ -313,26 +332,19 @@ func updateNginxBrotliParams(params []dto.NginxParam) error { return updateUserNginxBrotliParams(install, values) } // A managed write needs the include. Installations missing it are - // upgraded in place here; when nginx.conf cannot be edited safely the - // write is refused with an actionable error instead of writing values - // nginx would never load. - if !nginxHTTPIncludePresent(install) { - configPath := nginxMainConfigPath(install) - content, readErr := os.ReadFile(configPath) - if readErr != nil { - return readErr - } - updated, insErr := insertNginxHTTPInclude(string(content)) - if insErr != nil { + // upgraded in place here, through the same path that enabling the + // module takes; when nginx.conf cannot be edited safely the write is + // refused with an actionable error instead of writing values nginx + // would never load. + _, confSnapshot, includeErr := ensureNginxHTTPIncludeActive(install) + if includeErr != nil { + if errors.Is(includeErr, errNginxHTTPIncludeNoInsertionPoint) { return buserr.New("ErrBrotliUnsupported") } - if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil { - return err - } - if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil { - return err - } - if err = nginxCheckAndReload(string(content), configPath, install.ContainerName); err != nil { + return includeErr + } + if confSnapshot != nil { + if err = nginxCheckAndReload(string(confSnapshot), nginxMainConfigPath(install), install.ContainerName); err != nil { return err } } diff --git a/agent/app/service/nginx_module_runtime_test.go b/agent/app/service/nginx_module_runtime_test.go index 8be49c973..94614ee01 100644 --- a/agent/app/service/nginx_module_runtime_test.go +++ b/agent/app/service/nginx_module_runtime_test.go @@ -229,3 +229,85 @@ func TestValidateNginxBrotliValues(t *testing.T) { } } } + +// newBrotliStateInstall lays out an OpenResty install whose nginx.conf holds +// conf, or that has no nginx.conf at all when conf is empty. +func newBrotliStateInstall(t *testing.T, conf string) model.AppInstall { + t.Helper() + installRoot := t.TempDir() + confDir := path.Join(installRoot, "openresty", "openresty", "conf") + if err := os.MkdirAll(confDir, 0o755); err != nil { + t.Fatal(err) + } + if conf != "" { + if err := os.WriteFile(path.Join(confDir, "nginx.conf"), []byte(conf), 0o644); err != nil { + t.Fatal(err) + } + } + previous := global.Dir.AppInstallDir + global.Dir.AppInstallDir = installRoot + t.Cleanup(func() { global.Dir.AppInstallDir = previous }) + install := model.AppInstall{Name: "openresty"} + install.App.Key = constant.AppOpenresty + return install +} + +// Installs that enabled brotli before the panel managed its directives have +// no http.d include. Saving inserts it, so the settings page must report the +// values as pending rather than as something the panel can never apply. +func TestNginxBrotliManagedState(t *testing.T) { + withInclude, err := insertNginxHTTPInclude(plainNginxConf) + if err != nil { + t.Fatal(err) + } + managedFile := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName) + cases := []struct { + name string + conf string + managedFile bool + wantPending bool + wantUnavailable bool + }{ + {"include missing but insertable", plainNginxConf, false, true, false}, + {"no insertion point", "events {}\n", false, false, true}, + {"nginx.conf unreadable", "", false, false, true}, + {"include present, managed file missing", withInclude, false, true, false}, + {"include present and managed file written", withInclude, true, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + install := newBrotliStateInstall(t, tc.conf) + if tc.managedFile { + configDir := nginxHTTPConfigDir(install) + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path.Join(configDir, managedFile), []byte("brotli on;\n"), 0o644); err != nil { + t.Fatal(err) + } + } + pending, unavailable := nginxBrotliManagedState(install) + if pending != tc.wantPending || unavailable != tc.wantUnavailable { + t.Fatalf("expected pending=%v unavailable=%v, got pending=%v unavailable=%v", + tc.wantPending, tc.wantUnavailable, pending, unavailable) + } + }) + } +} + +// The state is computed on every visit to the settings page; only an explicit +// save may change the configuration. +func TestNginxBrotliManagedStateDoesNotWrite(t *testing.T) { + install := newBrotliStateInstall(t, plainNginxConf) + nginxBrotliManagedState(install) + content, err := os.ReadFile(nginxMainConfigPath(install)) + if err != nil { + t.Fatal(err) + } + if string(content) != plainNginxConf { + t.Fatal("checking the state must not edit nginx.conf") + } + if _, err := os.Stat(nginxHTTPConfigDir(install)); !os.IsNotExist(err) { + t.Fatal("checking the state must not create the http.d directory") + } +} diff --git a/frontend/src/api/interface/nginx.ts b/frontend/src/api/interface/nginx.ts index c2d08b967..f02a6f35a 100644 --- a/frontend/src/api/interface/nginx.ts +++ b/frontend/src/api/interface/nginx.ts @@ -10,6 +10,7 @@ export namespace Nginx { export interface NginxBrotliRes { params: NginxParam[]; managedExternally: boolean; + managedPending: boolean; managedUnavailable: boolean; } diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index c88f78a2f..9ed13b34e 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -4591,6 +4591,7 @@ const message = { brotliCompLevelHelper: 'Brotli compression rate, 0 to 11', brotliManagedExternallyHelper: 'Brotli is configured manually in nginx.conf; the panel shows the values in effect and will not overwrite them.', + brotliManagedPendingHelper: 'Brotli settings are not in effect yet; they take effect after you save.', brotliManagedUnavailableHelper: 'The panel could not add the managed brotli configuration to nginx.conf automatically; the values below will not take effect.', brotliMinLengthHelper: 'Minimum response size to brotli-compress', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 736bca55e..790ead4a3 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -4650,6 +4650,7 @@ const message = { brotliCompLevelHelper: 'Tasa de compresión brotli, de 0 a 11', brotliManagedExternallyHelper: 'Brotli está configurado manualmente en nginx.conf; el panel muestra los valores en vigor y no los sobrescribe.', + brotliManagedPendingHelper: 'La configuración de brotli aún no está en vigor; se aplicará al guardar.', brotliManagedUnavailableHelper: 'El panel no pudo añadir automáticamente la configuración brotli gestionada a nginx.conf; los valores siguientes no tendrán efecto.', brotliMinLengthHelper: 'Tamaño mínimo de respuesta para comprimir con brotli', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 4e24d7856..031552165 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -4553,6 +4553,7 @@ const message = { brotliCompLevelHelper: 'نرخ فشرده‌سازی brotli، از 0 تا 11', brotliManagedExternallyHelper: 'brotli به‌صورت دستی در nginx.conf پیکربندی شده است؛ پنل فقط مقادیر در حال اجرا را نشان می‌دهد و آن‌ها را بازنویسی نمی‌کند.', + brotliManagedPendingHelper: 'تنظیمات brotli هنوز اعمال نشده است؛ پس از ذخیره اعمال می‌شود.', brotliManagedUnavailableHelper: 'پنل نتوانست پیکربندی مدیریت‌شده brotli را به‌صورت خودکار به nginx.conf اضافه کند؛ مقادیر زیر اعمال نخواهند شد.', brotliMinLengthHelper: 'حداقل اندازه پاسخ برای فشرده‌سازی با brotli', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 3eb926db3..41c347494 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -4574,6 +4574,7 @@ const message = { brotliCompLevelHelper: 'brotli 圧縮率、0 から 11', brotliManagedExternallyHelper: 'brotli は nginx.conf で手動設定されています。パネルは現在の有効な値を表示するだけで、上書きしません。', + brotliManagedPendingHelper: 'brotli 設定はまだ有効になっていません。保存すると有効になります。', brotliManagedUnavailableHelper: 'パネルは管理対象の brotli 設定を nginx.conf に自動で追加できませんでした。以下の設定は有効になりません。', brotliMinLengthHelper: 'brotli で圧縮する最小レスポンスサイズ', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index c3ecd596d..cd2d2b190 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -4492,6 +4492,7 @@ const message = { brotliCompLevelHelper: 'brotli 압축률, 0에서 11까지', brotliManagedExternallyHelper: 'brotli가 nginx.conf에 수동으로 구성되어 있습니다. 패널은 적용 중인 값만 표시하며 덮어쓰지 않습니다.', + brotliManagedPendingHelper: 'brotli 설정이 아직 적용되지 않았습니다. 저장하면 적용됩니다.', brotliManagedUnavailableHelper: '패널이 관리되는 brotli 구성을 nginx.conf에 자동으로 추가할 수 없습니다. 아래 설정은 적용되지 않습니다.', brotliMinLengthHelper: 'brotli로 압축할 최소 응답 크기', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index ba6d05677..40915108d 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -4465,6 +4465,7 @@ const message = { brotliCompLevelHelper: 'ອັດຕາການບີບອັດ brotli, 0 ຫາ 11', brotliManagedExternallyHelper: 'brotli ຖືກຕັ້ງຄ່າດ້ວຍມືໃນ nginx.conf; ແຜງຄວບຄຸມຈະສະແດງຄ່າທີ່ກຳລັງໃຊ້ຢູ່ ແລະ ຈະບໍ່ຂຽບທັບ.', + brotliManagedPendingHelper: 'ການຕັ້ງຄ່າ brotli ຍັງບໍ່ມີຜົນ; ຈະມີຜົນຫຼັງຈາກບັນທຶກ.', brotliManagedUnavailableHelper: 'ແຜງຄວບຄຸມບໍ່ສາມາດເພີ່ມການຕັ້ງຄ່າ brotli ທີ່ຈັດການໄປຍັງ nginx.conf ໂດຍອັດຕະໂນມັດໄດ້; ຄ່າຂ້າງລຸ່ມນີ້ຈະບໍ່ມີຜົນ.', brotliMinLengthHelper: 'ຂະໜາດຕອບສະໜອງຂັ້ນຕ່ຳທີ່ຈະບີບອັດດ້ວຍ brotli', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 17bb7cd39..1bd6de626 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -4659,6 +4659,7 @@ const message = { brotliCompLevelHelper: 'Kadar pemampatan brotli, 0 hingga 11', brotliManagedExternallyHelper: 'Brotli dikonfigurasikan secara manual dalam nginx.conf; panel memaparkan nilai yang berkuat kuasa dan tidak akan menimpanya.', + brotliManagedPendingHelper: 'Tetapan brotli belum berkuat kuasa; ia akan berkuat kuasa selepas disimpan.', brotliManagedUnavailableHelper: 'Panel tidak dapat menambahkan konfigurasi brotli terurus ke nginx.conf secara automatik; nilai di bawah tidak akan berkuat kuasa.', brotliMinLengthHelper: 'Saiz respons minimum untuk dimampatkan dengan brotli', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 7b421c348..38da9dd51 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -4678,6 +4678,7 @@ const message = { brotliCompLevelHelper: 'Taxa de compressão brotli, de 0 a 11', brotliManagedExternallyHelper: 'O brotli está configurado manualmente no nginx.conf; o painel mostra os valores em vigor e não os sobrescreve.', + brotliManagedPendingHelper: 'As configurações brotli ainda não estão em vigor; serão aplicadas ao salvar.', brotliManagedUnavailableHelper: 'O painel não conseguiu adicionar automaticamente a configuração brotli gerenciada ao nginx.conf; os valores abaixo não terão efeito.', brotliMinLengthHelper: 'Tamanho mínimo da resposta para compressão brotli', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index af750ce03..a204355f9 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -4649,6 +4649,7 @@ const message = { brotliCompLevelHelper: 'Степень сжатия brotli, от 0 до 11', brotliManagedExternallyHelper: 'Brotli настроен вручную в nginx.conf; панель показывает действующие значения и не перезаписывает их.', + brotliManagedPendingHelper: 'Настройки brotli ещё не действуют; они вступят в силу после сохранения.', brotliManagedUnavailableHelper: 'Панель не смогла автоматически добавить управляемую конфигурацию brotli в nginx.conf; значения ниже не вступят в силу.', brotliMinLengthHelper: 'Минимальный размер ответа для сжатия brotli', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 0980d1d2f..9c261cb86 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -4664,6 +4664,7 @@ const message = { brotliCompLevelHelper: 'Brotli sıkıştırma oranı, 0 ile 11 arası', brotliManagedExternallyHelper: 'Brotli, nginx.conf dosyasında elle yapılandırıldı; panel yalnızca geçerli değerleri gösterir ve üzerine yazmaz.', + brotliManagedPendingHelper: 'Brotli ayarları henüz etkin değil; kaydettikten sonra etkinleşir.', brotliManagedUnavailableHelper: 'Panel, yönetilen brotli yapılandırmasını nginx.conf dosyasına otomatik olarak ekleyemedi; aşağıdaki değerler etkin olmayacak.', brotliMinLengthHelper: 'brotli ile sıkıştırılacak minimum yanıt boyutu', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 2534135db..1da913a9b 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -4292,6 +4292,7 @@ const message = { brotliHelper: '開啟 brotli 壓縮,通常比 gzip 體積更小', brotliCompLevelHelper: 'brotli 壓縮率,取值 0 到 11', brotliManagedExternallyHelper: 'brotli 已在 nginx.conf 中手動設定,面板僅顯示目前生效值,不會覆寫。', + brotliManagedPendingHelper: 'brotli 設定尚未生效,點擊儲存後生效。', brotliManagedUnavailableHelper: '面板無法自動向 nginx.conf 寫入託管的 brotli 設定,以下設定不會生效。', brotliMinLengthHelper: 'brotli 壓縮的最小回應大小', brotliSaveFailed: 'brotli 設定儲存失敗,上方的 gzip 設定已套用', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index eb3686edd..cfc4729d9 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -4332,6 +4332,7 @@ const message = { brotliHelper: '开启 brotli 压缩,通常比 gzip 体积更小', brotliCompLevelHelper: 'brotli 压缩率,取值 0 到 11', brotliManagedExternallyHelper: 'brotli 已在 nginx.conf 中手动配置,面板仅显示当前生效值,不会覆盖。', + brotliManagedPendingHelper: 'brotli 配置尚未生效,点击保存后生效。', brotliManagedUnavailableHelper: '面板无法自动向 nginx.conf 写入托管的 brotli 配置,以下设置不会生效。', brotliMinLengthHelper: 'brotli 压缩的最小响应大小', brotliSaveFailed: 'brotli 设置保存失败,上方的 gzip 设置已应用', diff --git a/frontend/src/views/website/website/nginx/performance/index.vue b/frontend/src/views/website/website/nginx/performance/index.vue index 98a0074d8..cabeaf45b 100644 --- a/frontend/src/views/website/website/nginx/performance/index.vue +++ b/frontend/src/views/website/website/nginx/performance/index.vue @@ -54,6 +54,11 @@ {{ $t('nginx.brotliManagedExternallyHelper') }} + + + {{ $t('nginx.brotliManagedPendingHelper') }} + + {{ $t('nginx.brotliManagedUnavailableHelper') }} @@ -127,6 +132,8 @@ let loading = ref(false); // disappear together with the module. const brotliAvailable = ref(false); const brotliManagedExternally = ref(false); +// Pending: nginx does not load the managed file yet, and saving wires it in. +const brotliManagedPending = ref(false); const brotliManagedUnavailable = ref(false); const variablesRules = reactive({ @@ -201,6 +208,7 @@ const getBrotliParams = async () => { } const res = await getNginxConfigByScope({ scope: 'brotli' }); brotliManagedExternally.value = !!res.data?.managedExternally; + brotliManagedPending.value = !!res.data?.managedPending; brotliManagedUnavailable.value = !!res.data?.managedUnavailable; for (const param of res.data?.params ?? []) { if (param.params.length === 0) {