diff --git a/packages/backend.ai-ui/src/__generated__/BAIStorageProxySelectQuery.graphql.ts b/packages/backend.ai-ui/src/__generated__/BAIStorageProxySelectQuery.graphql.ts new file mode 100644 index 0000000000..fd5d4dc6ff --- /dev/null +++ b/packages/backend.ai-ui/src/__generated__/BAIStorageProxySelectQuery.graphql.ts @@ -0,0 +1,139 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type BAIStorageProxySelectQuery$variables = { + limit: number; +}; +export type BAIStorageProxySelectQuery$data = { + readonly storage_volume_list: { + readonly items: ReadonlyArray<{ + readonly proxy: string | null | undefined; + } | null | undefined>; + } | null | undefined; +}; +export type BAIStorageProxySelectQuery = { + response: BAIStorageProxySelectQuery$data; + variables: BAIStorageProxySelectQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "limit" + } +], +v1 = [ + { + "kind": "Variable", + "name": "limit", + "variableName": "limit" + }, + { + "kind": "Literal", + "name": "offset", + "value": 0 + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "proxy", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "BAIStorageProxySelectQuery", + "selections": [ + { + "alias": null, + "args": (v1/*: any*/), + "concreteType": "StorageVolumeList", + "kind": "LinkedField", + "name": "storage_volume_list", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "StorageVolume", + "kind": "LinkedField", + "name": "items", + "plural": true, + "selections": [ + (v2/*: any*/) + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "BAIStorageProxySelectQuery", + "selections": [ + { + "alias": null, + "args": (v1/*: any*/), + "concreteType": "StorageVolumeList", + "kind": "LinkedField", + "name": "storage_volume_list", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "StorageVolume", + "kind": "LinkedField", + "name": "items", + "plural": true, + "selections": [ + (v2/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "b18d8c964bbf7af60d564f4fa0f82338", + "id": null, + "metadata": {}, + "name": "BAIStorageProxySelectQuery", + "operationKind": "query", + "text": "query BAIStorageProxySelectQuery(\n $limit: Int!\n) {\n storage_volume_list(limit: $limit, offset: 0) {\n items {\n proxy\n id\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "e0ada7211322acef77d2fdb321215b0b"; + +export default node; diff --git a/packages/backend.ai-ui/src/components/fragments/BAIStorageProxySelect.stories.tsx b/packages/backend.ai-ui/src/components/fragments/BAIStorageProxySelect.stories.tsx new file mode 100644 index 0000000000..4275d30bfe --- /dev/null +++ b/packages/backend.ai-ui/src/components/fragments/BAIStorageProxySelect.stories.tsx @@ -0,0 +1,367 @@ +import RelayResolver from '../../tests/RelayResolver'; +import BAIStorageProxySelect from './BAIStorageProxySelect'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; + +// ============================================================================= +// Mock Data +// ============================================================================= + +// The component derives the proxy list from `storage_volume_list.items[].proxy`, +// so mock data is shaped as volumes (a proxy can back multiple volumes). +const sampleVolumes = [ + { proxy: 'local' }, + { proxy: 'ceph-proxy' }, + { proxy: 'pure-proxy' }, +]; + +// Multiple volumes share the same proxy — the component dedupes to distinct +// proxy names (3 unique proxies from 6 volumes here). +const sampleVolumesWithDuplicateProxies = [ + { proxy: 'local' }, + { proxy: 'local' }, + { proxy: 'ceph-proxy' }, + { proxy: 'ceph-proxy' }, + { proxy: 'pure-proxy' }, + { proxy: 'local' }, +]; + +const sampleManyVolumes = Array.from({ length: 15 }, (_, i) => ({ + proxy: `proxy-${i + 1}`, +})); + +/** + * BAIStorageProxySelect is a specialized Select component that fetches storage + * volumes and derives the distinct storage-proxy names from them using GraphQL. + * + * Key features: + * - Automatic data fetching via GraphQL query + * - Distinct proxy names are derived from the volume list and deduplicated + * - Built-in search functionality + * - Internationalized placeholder text + * + * @see BAIStorageProxySelect.tsx for implementation details + */ +const meta: Meta = { + title: 'Fragments/BAIStorageProxySelect', + component: BAIStorageProxySelect, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +**BAIStorageProxySelect** extends [BAISelect](/?path=/docs/components-input-baiselect--docs) to fetch storage volumes and present the distinct storage-proxy names. + +## Features +- Fetches volumes from GraphQL query \`BAIStorageProxySelectQuery\` and derives distinct proxy names +- Automatically removes duplicate proxy names using \`_.uniq\` + \`_.compact\` +- Built-in search functionality enabled by default +- Internationalized placeholder using \`comp:BAIStorageProxySelect.SelectStorageProxy\` +- Uses the proxy name as both label and value + +## GraphQL Query +\`\`\`graphql +query BAIStorageProxySelectQuery($limit: Int!) { + storage_volume_list(limit: $limit, offset: 0) { + items { + proxy + } + } +} +\`\`\` + +There is no dedicated storage-proxy list field yet, so proxies are derived from the volume list (see the \`TODO(needs-backend)\` in the source). + +## Usage +\`\`\`tsx + console.log(value)} +/> +\`\`\` + +For all other props, refer to [BAISelect](/?path=/docs/components-input-baiselect--docs). + `, + }, + }, + }, + argTypes: { + placeholder: { + control: { type: 'text' }, + description: 'Placeholder text when no value is selected', + table: { + type: { summary: 'string' }, + defaultValue: { + summary: 'i18n: comp:BAIStorageProxySelect.SelectStorageProxy', + }, + }, + }, + autoSelectOption: { + control: { type: 'boolean' }, + description: + 'Auto-select the first option once options load and no value is set', + table: { + type: { summary: 'boolean | ((options) => value)' }, + defaultValue: { summary: 'undefined' }, + }, + }, + disabled: { + control: { type: 'boolean' }, + description: 'Whether the select is disabled', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + }, + allowClear: { + control: { type: 'boolean' }, + description: 'Show clear button', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + }, + onChange: { + action: 'changed', + description: 'Callback when selection changes', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * Basic usage with 3 sample storage proxies. + */ +export const Default: Story = { + name: 'Basic', + parameters: { + docs: { + description: { + story: + 'Basic usage showing 3 storage proxies. Search functionality is enabled by default, and the placeholder is internationalized.', + }, + }, + }, + args: {}, + render: (args) => ( + ({ + storage_volume_list: { items: sampleVolumes }, + }), + }} + > + + + ), +}; + +/** + * Empty state when no storage volumes (and thus no proxies) are available. + */ +export const Empty: Story = { + name: 'EmptyState', + parameters: { + docs: { + description: { + story: 'Shows the component when no storage proxies are configured.', + }, + }, + }, + args: {}, + render: (args) => ( + ({ + storage_volume_list: { items: [] }, + }), + }} + > + + + ), +}; + +/** + * Automatic deduplication of proxy names derived from multiple volumes. + */ +export const WithDuplicates: Story = { + name: 'AutomaticDeduplication', + parameters: { + docs: { + description: { + story: + 'Demonstrates automatic deduplication when multiple volumes share the same proxy. Only distinct proxy names are shown (3 unique proxies from 6 volumes).', + }, + }, + }, + args: {}, + render: (args) => ( + ({ + storage_volume_list: { items: sampleVolumesWithDuplicateProxies }, + }), + }} + > + + + ), +}; + +/** + * Auto-select the first proxy once options load. + */ +export const AutoSelectFirst: Story = { + name: 'AutoSelectFirstOption', + parameters: { + docs: { + description: { + story: + 'With `autoSelectOption`, the first proxy is selected automatically once options load and no value is set — used by the SFTP Resource Group settings modal. This requires a controlled `value`/`onChange` pair (as a real consumer, e.g. an antd `Form.Item`, would provide) — `autoSelectOption` only *calls* `onChange`, it does not maintain the selection itself.', + }, + }, + }, + args: { + autoSelectOption: true, + }, + render: (args) => { + const [value, setValue] = useState(); + return ( + ({ + storage_volume_list: { items: sampleVolumes }, + }), + }} + > + + + ); + }, +}; + +/** + * Multiple selection mode, picking from existing proxy options. + */ +export const MultiSelect: Story = { + name: 'MultipleSelection', + parameters: { + docs: { + description: { + story: + 'With `mode="multiple"`, users can select several proxies from the existing options (as opposed to `mode="tags"`, which also allows free text entry).', + }, + }, + }, + args: { + mode: 'multiple', + defaultValue: ['local', 'ceph-proxy'], + }, + render: (args) => ( + ({ + storage_volume_list: { items: sampleVolumes }, + }), + }} + > + + + ), +}; + +/** + * Disabled state of the select. + */ +export const Disabled: Story = { + name: 'DisabledState', + parameters: { + docs: { + description: { + story: + 'Shows the component in a disabled state where users cannot interact with it.', + }, + }, + }, + args: { + disabled: true, + }, + render: (args) => ( + ({ + storage_volume_list: { items: sampleVolumes }, + }), + }} + > + + + ), +}; + +/** + * Select with clear button enabled. + */ +export const WithClearButton: Story = { + name: 'ClearButton', + parameters: { + docs: { + description: { + story: + 'Select with allowClear enabled, allowing users to clear their selection.', + }, + }, + }, + args: { + allowClear: true, + }, + render: (args) => ( + ({ + storage_volume_list: { items: sampleVolumes }, + }), + }} + > + + + ), +}; + +/** + * Select with many storage proxies. + */ +export const ManyProxies: Story = { + name: 'ManyOptions', + parameters: { + docs: { + description: { + story: + 'Demonstrates the component with 15 storage proxies, showing a scrollable dropdown with search functionality.', + }, + }, + }, + args: { + allowClear: true, + }, + render: (args) => ( + ({ + storage_volume_list: { items: sampleManyVolumes }, + }), + }} + > + + + ), +}; diff --git a/packages/backend.ai-ui/src/components/fragments/BAIStorageProxySelect.tsx b/packages/backend.ai-ui/src/components/fragments/BAIStorageProxySelect.tsx new file mode 100644 index 0000000000..bb6569d070 --- /dev/null +++ b/packages/backend.ai-ui/src/components/fragments/BAIStorageProxySelect.tsx @@ -0,0 +1,51 @@ +import { BAIStorageProxySelectQuery } from '../../__generated__/BAIStorageProxySelectQuery.graphql'; +import { useBAIi18n } from '../../hooks/useBAIi18n'; +import BAISelect, { BAISelectProps } from '../BAISelect'; +import * as _ from 'lodash-es'; +import { graphql, useLazyLoadQuery } from 'react-relay'; + +export interface BAIStorageProxySelectProps extends Omit< + BAISelectProps, + 'options' +> {} + +// TODO(needs-backend, FR-3243): there is no dedicated storage-proxy list +// field; proxies are derived from the (offset-paginated) volume list. Swap +// this for a dedicated field once one exists. A real deployment's volume +// count is nowhere near this ceiling, so a single fixed-size fetch is enough +// to surface every distinct proxy in the meantime. +const VOLUME_FETCH_LIMIT = 1000; + +const BAIStorageProxySelect = (selectProps: BAIStorageProxySelectProps) => { + 'use memo'; + const { t } = useBAIi18n(); + + const { storage_volume_list } = useLazyLoadQuery( + graphql` + query BAIStorageProxySelectQuery($limit: Int!) { + storage_volume_list(limit: $limit, offset: 0) { + items { + proxy + } + } + } + `, + { limit: VOLUME_FETCH_LIMIT }, + {}, + ); + + const proxies = _.uniq( + _.compact(_.map(storage_volume_list?.items, (item) => item?.proxy)), + ); + + return ( + ({ label: proxy, value: proxy }))} + showSearch + placeholder={t('comp:BAIStorageProxySelect.SelectStorageProxy')} + {...selectProps} + /> + ); +}; + +export default BAIStorageProxySelect; diff --git a/packages/backend.ai-ui/src/components/fragments/index.ts b/packages/backend.ai-ui/src/components/fragments/index.ts index d207844abd..4ea26a8b1c 100644 --- a/packages/backend.ai-ui/src/components/fragments/index.ts +++ b/packages/backend.ai-ui/src/components/fragments/index.ts @@ -78,6 +78,8 @@ export { default as BAIProjectResourcePolicySelect } from './BAIProjectResourceP export type { BAIProjectResourcePolicySelectProps } from './BAIProjectResourcePolicySelect'; export { default as BAIResourceGroupSelect } from './BAIResourceGroupSelect'; export type { BAIResourceGroupSelectProps } from './BAIResourceGroupSelect'; +export { default as BAIStorageProxySelect } from './BAIStorageProxySelect'; +export type { BAIStorageProxySelectProps } from './BAIStorageProxySelect'; export { default as BAIProjectBulkEditModal } from './BAIProjectBulkEditModal'; export type { BAIProjectBulkEditModalProps } from './BAIProjectBulkEditModal'; export { default as BAIAgentTable } from './BAIAgentTable'; diff --git a/packages/backend.ai-ui/src/locale/de.json b/packages/backend.ai-ui/src/locale/de.json index 56f8ce087f..1e0e90eaea 100644 --- a/packages/backend.ai-ui/src/locale/de.json +++ b/packages/backend.ai-ui/src/locale/de.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Speicher-Host auswählen" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Speicher-Proxy auswählen" + }, "comp:BAISubStepNodes": { "EndedAt": "Beendet um", "ErrorCode": "Fehlercode", diff --git a/packages/backend.ai-ui/src/locale/el.json b/packages/backend.ai-ui/src/locale/el.json index 6269c9f660..effca2ce53 100644 --- a/packages/backend.ai-ui/src/locale/el.json +++ b/packages/backend.ai-ui/src/locale/el.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Επιλέξτε κεντρικό αποθηκευτικό χώρο" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Επιλέξτε proxy αποθήκευσης" + }, "comp:BAISubStepNodes": { "EndedAt": "Έληξε στις", "ErrorCode": "Κωδικός σφάλματος", diff --git a/packages/backend.ai-ui/src/locale/en.json b/packages/backend.ai-ui/src/locale/en.json index a3503b3d08..2fc01c6c33 100644 --- a/packages/backend.ai-ui/src/locale/en.json +++ b/packages/backend.ai-ui/src/locale/en.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Select Storage Host" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Select Storage Proxy" + }, "comp:BAISubStepNodes": { "EndedAt": "Ended At", "ErrorCode": "Error Code", diff --git a/packages/backend.ai-ui/src/locale/es.json b/packages/backend.ai-ui/src/locale/es.json index a7ae4bacb4..bfbe119a8d 100644 --- a/packages/backend.ai-ui/src/locale/es.json +++ b/packages/backend.ai-ui/src/locale/es.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Seleccionar host de almacenamiento" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Seleccionar proxy de almacenamiento" + }, "comp:BAISubStepNodes": { "EndedAt": "Finalizado en", "ErrorCode": "Código de error", diff --git a/packages/backend.ai-ui/src/locale/fi.json b/packages/backend.ai-ui/src/locale/fi.json index d5b86cdae0..cae6821858 100644 --- a/packages/backend.ai-ui/src/locale/fi.json +++ b/packages/backend.ai-ui/src/locale/fi.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Valitse tallennuspalvelin" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Valitse tallennusvälityspalvelin" + }, "comp:BAISubStepNodes": { "EndedAt": "Päättymisaika", "ErrorCode": "Virhekoodi", diff --git a/packages/backend.ai-ui/src/locale/fr.json b/packages/backend.ai-ui/src/locale/fr.json index eb024ef0da..e093d593c9 100644 --- a/packages/backend.ai-ui/src/locale/fr.json +++ b/packages/backend.ai-ui/src/locale/fr.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Sélectionner un hôte de stockage" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Sélectionner un proxy de stockage" + }, "comp:BAISubStepNodes": { "EndedAt": "Terminé le", "ErrorCode": "Code d'erreur", diff --git a/packages/backend.ai-ui/src/locale/id.json b/packages/backend.ai-ui/src/locale/id.json index faa69025fb..1600eeba8e 100644 --- a/packages/backend.ai-ui/src/locale/id.json +++ b/packages/backend.ai-ui/src/locale/id.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Pilih Host Penyimpanan" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Pilih Proksi Penyimpanan" + }, "comp:BAISubStepNodes": { "EndedAt": "Berakhir pada", "ErrorCode": "Kode Kesalahan", diff --git a/packages/backend.ai-ui/src/locale/it.json b/packages/backend.ai-ui/src/locale/it.json index 586f0e680d..238f668f73 100644 --- a/packages/backend.ai-ui/src/locale/it.json +++ b/packages/backend.ai-ui/src/locale/it.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Seleziona host di archiviazione" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Seleziona proxy di archiviazione" + }, "comp:BAISubStepNodes": { "EndedAt": "Terminato il", "ErrorCode": "Codice di errore", diff --git a/packages/backend.ai-ui/src/locale/ja.json b/packages/backend.ai-ui/src/locale/ja.json index 9bcd9534ef..1c2f87a1fb 100644 --- a/packages/backend.ai-ui/src/locale/ja.json +++ b/packages/backend.ai-ui/src/locale/ja.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "ストレージホストを選択" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "ストレージプロキシを選択" + }, "comp:BAISubStepNodes": { "EndedAt": "終了日時", "ErrorCode": "エラーコード", diff --git a/packages/backend.ai-ui/src/locale/ko.json b/packages/backend.ai-ui/src/locale/ko.json index 159d5f0c6d..9de7122ecb 100644 --- a/packages/backend.ai-ui/src/locale/ko.json +++ b/packages/backend.ai-ui/src/locale/ko.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "스토리지 호스트를 선택해주세요" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "스토리지 프록시를 선택해주세요" + }, "comp:BAISubStepNodes": { "EndedAt": "종료일", "ErrorCode": "오류 코드", diff --git a/packages/backend.ai-ui/src/locale/mn.json b/packages/backend.ai-ui/src/locale/mn.json index d532f4b023..65f9c73add 100644 --- a/packages/backend.ai-ui/src/locale/mn.json +++ b/packages/backend.ai-ui/src/locale/mn.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Хадгалах хост сонгох" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Хадгалалтын прокси сонгох" + }, "comp:BAISubStepNodes": { "EndedAt": "Дууссан цаг", "ErrorCode": "Алдааны код", diff --git a/packages/backend.ai-ui/src/locale/ms.json b/packages/backend.ai-ui/src/locale/ms.json index e67e899fd6..f508bde114 100644 --- a/packages/backend.ai-ui/src/locale/ms.json +++ b/packages/backend.ai-ui/src/locale/ms.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Pilih Hos Storan" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Pilih Proksi Storan" + }, "comp:BAISubStepNodes": { "EndedAt": "Berakhir pada", "ErrorCode": "Kod Ralat", diff --git a/packages/backend.ai-ui/src/locale/pl.json b/packages/backend.ai-ui/src/locale/pl.json index bc7714af98..302bf79721 100644 --- a/packages/backend.ai-ui/src/locale/pl.json +++ b/packages/backend.ai-ui/src/locale/pl.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Wybierz host pamięci masowej" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Wybierz serwer proxy pamięci masowej" + }, "comp:BAISubStepNodes": { "EndedAt": "Zakończono o", "ErrorCode": "Kod błędu", diff --git a/packages/backend.ai-ui/src/locale/pt-BR.json b/packages/backend.ai-ui/src/locale/pt-BR.json index 5280a34d78..d2a98a6a84 100644 --- a/packages/backend.ai-ui/src/locale/pt-BR.json +++ b/packages/backend.ai-ui/src/locale/pt-BR.json @@ -368,6 +368,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Selecionar host de armazenamento" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Selecionar proxy de armazenamento" + }, "comp:BAISubStepNodes": { "EndedAt": "Finalizado em", "ErrorCode": "Código de erro", diff --git a/packages/backend.ai-ui/src/locale/pt.json b/packages/backend.ai-ui/src/locale/pt.json index 219662b8e3..8b6c0452cc 100644 --- a/packages/backend.ai-ui/src/locale/pt.json +++ b/packages/backend.ai-ui/src/locale/pt.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Selecionar host de armazenamento" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Selecionar proxy de armazenamento" + }, "comp:BAISubStepNodes": { "EndedAt": "Finalizado em", "ErrorCode": "Código de erro", diff --git a/packages/backend.ai-ui/src/locale/ru.json b/packages/backend.ai-ui/src/locale/ru.json index c4da5bbf8d..58766fcbcc 100644 --- a/packages/backend.ai-ui/src/locale/ru.json +++ b/packages/backend.ai-ui/src/locale/ru.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Выберите хост хранилища" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Выберите прокси хранилища" + }, "comp:BAISubStepNodes": { "EndedAt": "Завершено в", "ErrorCode": "Код ошибки", diff --git a/packages/backend.ai-ui/src/locale/th.json b/packages/backend.ai-ui/src/locale/th.json index 7af5fc154c..222b7bc764 100644 --- a/packages/backend.ai-ui/src/locale/th.json +++ b/packages/backend.ai-ui/src/locale/th.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "เลือกโฮสต์จัดเก็บข้อมูล" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "เลือกพร็อกซีที่เก็บข้อมูล" + }, "comp:BAISubStepNodes": { "EndedAt": "สิ้นสุดเมื่อ", "ErrorCode": "รหัสข้อผิดพลาด", diff --git a/packages/backend.ai-ui/src/locale/tr.json b/packages/backend.ai-ui/src/locale/tr.json index aa2ef89122..4a99747d19 100644 --- a/packages/backend.ai-ui/src/locale/tr.json +++ b/packages/backend.ai-ui/src/locale/tr.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Depolama Sunucusu Seç" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Depolama Proxy'si Seç" + }, "comp:BAISubStepNodes": { "EndedAt": "Bitiş Zamanı", "ErrorCode": "Hata Kodu", diff --git a/packages/backend.ai-ui/src/locale/vi.json b/packages/backend.ai-ui/src/locale/vi.json index a7dd4277f5..c031f7001b 100644 --- a/packages/backend.ai-ui/src/locale/vi.json +++ b/packages/backend.ai-ui/src/locale/vi.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "Chọn máy chủ lưu trữ" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "Chọn proxy lưu trữ" + }, "comp:BAISubStepNodes": { "EndedAt": "Kết thúc lúc", "ErrorCode": "Mã lỗi", diff --git a/packages/backend.ai-ui/src/locale/zh-CN.json b/packages/backend.ai-ui/src/locale/zh-CN.json index 176db0868f..23ec15caed 100644 --- a/packages/backend.ai-ui/src/locale/zh-CN.json +++ b/packages/backend.ai-ui/src/locale/zh-CN.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "选择存储主机" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "选择存储代理" + }, "comp:BAISubStepNodes": { "EndedAt": "结束时间", "ErrorCode": "错误代码", diff --git a/packages/backend.ai-ui/src/locale/zh-TW.json b/packages/backend.ai-ui/src/locale/zh-TW.json index 213df2c514..da806babeb 100644 --- a/packages/backend.ai-ui/src/locale/zh-TW.json +++ b/packages/backend.ai-ui/src/locale/zh-TW.json @@ -362,6 +362,9 @@ "comp:BAIStorageHostSelect": { "SelectStorageHost": "選擇儲存主機" }, + "comp:BAIStorageProxySelect": { + "SelectStorageProxy": "選擇儲存代理" + }, "comp:BAISubStepNodes": { "EndedAt": "結束時間", "ErrorCode": "錯誤代碼", diff --git a/react/src/components/InviteFolderSettingModal.tsx b/react/src/components/InviteFolderSettingModal.tsx index d265b468fb..abb8cb3cf0 100644 --- a/react/src/components/InviteFolderSettingModal.tsx +++ b/react/src/components/InviteFolderSettingModal.tsx @@ -69,13 +69,11 @@ const InviteFolderSettingModal: React.FC = ({ ], queryFn: () => baiModalProps.open - ? baiRequestWithPromise({ + ? baiRequestWithPromise<{ shared: Array }>({ method: 'GET', url: `/folders/_/shared${vfolderId ? `?${new URLSearchParams({ vfolder_id: vfolderId }).toString()}` : ''}`, - }).then((res: { shared: Array }) => - _.sortBy(res.shared, 'shared_to.email'), - ) - : null, + }).then((res) => _.sortBy(res.shared, 'shared_to.email')) + : [], staleTime: 0, }); diff --git a/react/src/components/ResourceGroupList.tsx b/react/src/components/ResourceGroupList.tsx index e4a78e7146..ba61c00555 100644 --- a/react/src/components/ResourceGroupList.tsx +++ b/react/src/components/ResourceGroupList.tsx @@ -8,9 +8,13 @@ import { ResourceGroupListQuery$data, } from '../__generated__/ResourceGroupListQuery.graphql'; import { ResourceGroupListUpdateMutation } from '../__generated__/ResourceGroupListUpdateMutation.graphql'; +import { useSuspendedBackendaiClient } from '../hooks'; +import { useBAISettingUserState } from '../hooks/useBAISetting'; +import { useSFTPProxyResourceGroupsQuery } from '../hooks/useSFTPResourceGroups'; import BAIRadioGroup from './BAIRadioGroup'; import ResourceGroupInfoModal from './ResourceGroupInfoModal'; import ResourceGroupSettingModal from './ResourceGroupSettingModal'; +import SFTPResourceGroupSettingModal from './SFTPResourceGroupSettingModal'; import { CheckOutlined, CloseOutlined, @@ -19,7 +23,7 @@ import { SettingOutlined, } from '@ant-design/icons'; import { useToggle } from 'ahooks'; -import { App, theme } from 'antd'; +import { App, Tag, Tooltip, theme } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useUpdatableState, @@ -31,10 +35,13 @@ import { BAIDeleteConfirmModal, BAIFetchKeyButton, BAINameActionCell, + BAIQuestionIconWithTooltip, + BAISelectionLabel, + BAIUnmountAfterClose, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import { BanIcon, PlusIcon, UndoIcon } from 'lucide-react'; -import { useState, useTransition } from 'react'; +import React, { useState, useTransition } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useLazyLoadQuery, useMutation } from 'react-relay'; import { PayloadError } from 'relay-runtime'; @@ -55,17 +62,40 @@ type ResourceGroup = NonNullable< >; const ResourceGroupList: React.FC = () => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); const { message } = App.useApp(); + const baiClient = useSuspendedBackendaiClient(); const [activeType, setActiveType] = useState<'active' | 'inactive'>('active'); const [openCreateModal, { toggle: toggleOpenCreateModal }] = useToggle(false); const [openInfoModal, { toggle: toggleOpenInfoModal }] = useToggle(false); + const [openSFTPModal, setOpenSFTPModal] = useState(false); const [selectedResourceGroup, setSelectedResourceGroup] = useState(); const [selectedResourceGroupName, setSelectedResourceGroupName] = useState(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [columnOverrides, setColumnOverrides] = useBAISettingUserState( + 'table_column_overrides.ResourceGroupList', + ); const [fetchKey, updateFetchKey] = useUpdatableState('first'); + // Read the `proxy -> resource group names` map from etcd (superadmin-only), + // non-blocking (the table renders without waiting on it) and invalidated on + // every SFTP write so it stays fresh. Inverted to `group name -> proxies` + // to show, per row, which storage proxies handle that group's SFTP. + const { data: proxyResourceGroups } = useSFTPProxyResourceGroupsQuery({ + enabled: baiClient.is_superadmin, + }); + const proxiesByGroupName = _.mapValues( + _.groupBy( + _.flatMap(_.entries(proxyResourceGroups ?? {}), ([proxy, groupNames]) => + _.map(groupNames, (groupName) => ({ proxy, groupName })), + ), + 'groupName', + ), + (pairs) => _.map(pairs, 'proxy'), + ); const [isActiveTypePending, startActiveTypeTransition] = useTransition(); const [isPendingRefetch, startRefetchTransition] = useTransition(); @@ -208,15 +238,21 @@ const ResourceGroupList: React.FC = () => { }, }, }, - { - key: 'delete', - title: t('button.Delete'), - icon: , - type: 'danger', - onClick: () => { - setSelectedResourceGroupName(record?.name || ''); - }, - }, + // Deletion is only offered for deactivated groups (Inactive tab); + // active groups are deactivated first. + ...(activeType === 'inactive' + ? [ + { + key: 'delete', + title: t('button.Delete'), + icon: , + type: 'danger' as const, + onClick: () => { + setSelectedResourceGroupName(record?.name || ''); + }, + }, + ] + : []), ]} /> ), @@ -239,6 +275,35 @@ const ResourceGroupList: React.FC = () => { ); }, }, + { + key: 'sftp', + title: ( + + {t('storageProxy.SFTPStorageProxies')} + + + ), + // Reading the assignment needs superadmin (raw etcd), so only they see it. + hidden: !baiClient.is_superadmin, + render: (_value, record) => { + const proxies = record.name + ? (proxiesByGroupName[record.name] ?? []) + : []; + return proxies.length > 0 ? ( + + {_.map(proxies, (proxy) => ( + + {proxy} + + ))} + + ) : ( + '-' + ); + }, + }, { key: 'driver', title: t('resourceGroup.Driver'), @@ -266,6 +331,7 @@ const ResourceGroupList: React.FC = () => { onChange={(value) => { startActiveTypeTransition(() => { setActiveType(value.target.value); + setSelectedRowKeys([]); }); }} optionType="button" @@ -281,6 +347,21 @@ const ResourceGroupList: React.FC = () => { ]} /> + {baiClient.is_superadmin && selectedRowKeys.length > 0 && ( + + setSelectedRowKeys([])} + /> + + } + style={{ backgroundColor: token.colorInfoBg }} + onClick={() => setOpenSFTPModal(true)} + /> + + + )} { columns={columns} dataSource={filterOutNullAndUndefined(scaling_groups)} loading={isActiveTypePending} + rowSelection={ + baiClient.is_superadmin + ? { + type: 'checkbox', + selectedRowKeys, + onChange: (keys) => setSelectedRowKeys(keys), + } + : undefined + } + tableSettings={{ + columnOverrides, + onColumnOverridesChange: setColumnOverrides, + }} /> { } }} /> + + { + setOpenSFTPModal(false); + if (success) { + setSelectedRowKeys([]); + } + }} + /> + ); }; diff --git a/react/src/components/ResourceGroupSettingModal.tsx b/react/src/components/ResourceGroupSettingModal.tsx index 37d8fef58c..d15e725352 100644 --- a/react/src/components/ResourceGroupSettingModal.tsx +++ b/react/src/components/ResourceGroupSettingModal.tsx @@ -7,7 +7,13 @@ import { ResourceGroupSettingModalCreateMutation } from '../__generated__/Resour import { ResourceGroupSettingModalFragment$key } from '../__generated__/ResourceGroupSettingModalFragment.graphql'; import { ResourceGroupSettingModalUpdateMutation } from '../__generated__/ResourceGroupSettingModalUpdateMutation.graphql'; import { newLineToBrElement } from '../helper'; -import { useCurrentDomainValue } from '../hooks'; +import { useCurrentDomainValue, useSuspendedBackendaiClient } from '../hooks'; +import { + computeProxyDelta, + proxiesServingGroups, + useSFTPProxyResourceGroupsQuery, + useSFTPResourceGroups, +} from '../hooks/useSFTPResourceGroups'; import { ScalingGroupOpts } from './ResourceGroupList'; import { QuestionCircleOutlined } from '@ant-design/icons'; import { @@ -31,9 +37,11 @@ import { BAICard, BAIFlex, BAIDomainSelect, + BAIStorageProxySelect, + useBAILogger, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; -import { Suspense, useMemo, useRef } from 'react'; +import React, { Suspense, useDeferredValue, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useFragment, useMutation } from 'react-relay'; @@ -49,6 +57,7 @@ type FormInputType = { scheduler: string; pendingTimeout: number; retriesToSkip: number; + sftpProxies: string[]; }; interface ResourceGroupCreateModalProps extends ModalProps { @@ -61,10 +70,14 @@ const ResourceGroupSettingModal: React.FC = ({ onRequestClose, ...modalProps }) => { + 'use memo'; const { t } = useTranslation(); const { token } = theme.useToken(); const { message } = App.useApp(); + const { logger } = useBAILogger(); + const baiClient = useSuspendedBackendaiClient(); const currentDomain = useCurrentDomainValue(); + const { applyGroupProxyDelta } = useSFTPResourceGroups(); const formRef = useRef(null); const resourceGroup = useFragment( @@ -82,6 +95,23 @@ const ResourceGroupSettingModal: React.FC = ({ `, resourceGroupFrgmt, ); + + // Superadmin-only SFTP proxy membership for this group, read from etcd without + // suspending: gate on a deferred `open` so enabling runs in a transition. The + // field below skeletons until the map resolves, then mounts with its prefill + // as the Form.Item `initialValue` — the rest of the form renders immediately. + const deferredOpen = useDeferredValue(modalProps.open); + const { data: proxyResourceGroups, isFetching: isSftpMapFetching } = + useSFTPProxyResourceGroupsQuery({ + enabled: baiClient.is_superadmin && !!deferredOpen, + }); + const currentSftpProxies = proxiesServingGroups( + proxyResourceGroups, + resourceGroup?.name ? [resourceGroup.name] : [], + ); + const isSftpMapLoading = + proxyResourceGroups === undefined || isSftpMapFetching; + const [commitUpdateResourceGroup, isInFlightCommitUpdateResourceGroup] = useMutation(graphql` mutation ResourceGroupSettingModalUpdateMutation( @@ -192,6 +222,40 @@ const ResourceGroupSettingModal: React.FC = ({ wsproxy_api_token: values.wsProxyAPIToken, }; + // After the group is saved, sync its SFTP proxy membership to the + // selected proxies (add to newly-selected proxies, remove from + // deselected ones, preserving other groups on each proxy). + // Deselection here removes without a separate confirm — unlike the + // bulk modal — because Update is an explicit, per-group edit. + // Superadmin-only. + const applySftpProxies = (groupName: string): Promise => { + if (!baiClient.is_superadmin) { + return Promise.resolve(); + } + const { added, removed } = computeProxyDelta( + currentSftpProxies, + values.sftpProxies ?? [], + ); + return applyGroupProxyDelta([groupName], added, removed).then( + (results) => { + const failed = results.filter((r) => r.status === 'rejected'); + if (failed.length > 0) { + failed.forEach((r) => { + if (r.status === 'rejected') { + logger.error( + 'Failed to update SFTP storage proxies:', + r.reason, + ); + } + }); + message.error( + t('storageProxy.FailedToUpdateSFTPResourceGroups'), + ); + } + }, + ); + }; + if (resourceGroup) { commitUpdateResourceGroup({ variables: { @@ -214,7 +278,9 @@ const ResourceGroupSettingModal: React.FC = ({ return; } message.success(t('resourceGroup.ResourceGroupModified')); - onRequestClose?.(true); + applySftpProxies(resourceGroup.name).finally(() => + onRequestClose?.(true), + ); }, onError: (err) => { message.error(err.message); @@ -267,7 +333,9 @@ const ResourceGroupSettingModal: React.FC = ({ } message.success(t('resourceGroup.ResourceGroupCreated')); - onRequestClose?.(true); + applySftpProxies(values.name).finally(() => + onRequestClose?.(true), + ); }, onError: (err) => { message.error(err.message); @@ -357,6 +425,32 @@ const ResourceGroupSettingModal: React.FC = ({ > + {baiClient.is_superadmin ? ( + isSftpMapLoading ? ( + + + + ) : ( + // Wrap the Form.Item (not the select — that would break Form's + // value/onChange binding) in Suspense: BAIStorageProxySelect's + // proxy-options query suspends. The prefill is registered as the + // Form.Item `initialValue`, computed from the now-resolved map. + }> + + + + + ) + ) : null} void; +} + +const SFTPResourceGroupSettingModal: React.FC< + SFTPResourceGroupSettingModalProps +> = ({ resourceGroupNames, onRequestClose, open, ...baiModalProps }) => { + 'use memo'; + const { t } = useTranslation(); + const { message, modal } = App.useApp(); + const { logger } = useBAILogger(); + const [form] = Form.useForm(); + const { applyGroupProxyDelta } = useSFTPResourceGroups(); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Read the proxy -> resource-groups map without suspending: gate it on a + // deferred `open` so enabling the query runs in a transition, and surface the + // fetch through the modal's own loading skeleton (below) instead of a Suspense + // fallback. The modal body — and thus the form's `initialValues` — only mounts + // once the map resolves, so the prefill is always computed from real data. + const deferredOpen = useDeferredValue(open); + const { data: proxyResourceGroups, isFetching } = + useSFTPProxyResourceGroupsQuery({ enabled: !!deferredOpen }); + + // Proxies currently serving any of the selected groups: the field prefill and + // the baseline for the submit delta. + const currentProxies = proxiesServingGroups( + proxyResourceGroups, + resourceGroupNames, + ); + + const applyDelta = async (added: string[], removed: string[]) => { + setIsSubmitting(true); + const results = await applyGroupProxyDelta( + resourceGroupNames, + added, + removed, + ); + setIsSubmitting(false); + + const failed = results.filter((r) => r.status === 'rejected'); + if (failed.length > 0) { + failed.forEach((r) => { + if (r.status === 'rejected') { + logger.error('Failed to update SFTP resource groups:', r.reason); + } + }); + if (failed.length === results.length) { + message.error(t('storageProxy.FailedToUpdateSFTPResourceGroups')); + return; + } + } + message.success(t('storageProxy.SFTPResourceGroupsUpdated')); + onRequestClose(true); + }; + + const handleOk = () => { + return form + .validateFields() + .then((values) => { + const target: string[] = values.proxies ?? []; + const { added, removed } = computeProxyDelta(currentProxies, target); + + if (added.length === 0 && removed.length === 0) { + onRequestClose(true); + return; + } + // Removing a proxy strips SFTP handling from the selected groups, so + // confirm before a destructive change; pure additions apply directly. + if (removed.length > 0) { + modal.confirm({ + title: t('storageProxy.ConfirmRemoveSFTPProxiesTitle'), + content: ( + }} + /> + ), + okText: t('button.Remove'), + okButtonProps: { danger: true }, + cancelText: t('button.Cancel'), + onOk: () => applyDelta(added, removed), + }); + return; + } + return applyDelta(added, removed); + }) + .catch((error) => { + setIsSubmitting(false); + logger.error('SFTP resource group form validation failed:', error); + }); + }; + + return ( + onRequestClose(false)} + confirmLoading={isSubmitting} + loading={proxyResourceGroups === undefined || isFetching} + destroyOnHidden + {...baiModalProps} + > + + ({ + key: name, + content: name, + }))} + /> +
+ {/* The proxy options come from a Relay query that suspends, so wrap + the whole Form.Item (not the select — that would break Form's + value/onChange binding) in its own Suspense boundary. */} + }> + + + + +
+
+
+ ); +}; + +export default SFTPResourceGroupSettingModal; diff --git a/react/src/components/StorageProxyList.tsx b/react/src/components/StorageProxyList.tsx index 49ee8ba58b..7b02bce204 100644 --- a/react/src/components/StorageProxyList.tsx +++ b/react/src/components/StorageProxyList.tsx @@ -77,6 +77,7 @@ type StorageVolume = NonNullable< >; const StorageProxyList = () => { + 'use memo'; const { token } = theme.useToken(); const { t } = useTranslation(); const [drawerStorageHostId, setDrawerStorageHostId] = useState( diff --git a/react/src/helper/index.tsx b/react/src/helper/index.tsx index f5e0f3714f..9329b30a08 100644 --- a/react/src/helper/index.tsx +++ b/react/src/helper/index.tsx @@ -23,7 +23,7 @@ export const newLineToBrElement = ( }); }; -export const baiSignedRequestWithPromise = ({ +export const baiSignedRequestWithPromise = ({ method, url, body = null, @@ -33,14 +33,14 @@ export const baiSignedRequestWithPromise = ({ url: string; body?: any; client: any; -}) => { +}): Promise => { const request = client?.newSignedRequest(method, url, body, null); return client?._wrapWithPromise(request); }; export const useBaiSignedRequestWithPromise = () => { const baiClient = useSuspendedBackendaiClient(); - return ({ + return ({ method, url, body = null, @@ -48,8 +48,8 @@ export const useBaiSignedRequestWithPromise = () => { method: string; url: string; body?: any; - }) => - baiSignedRequestWithPromise({ + }): Promise => + baiSignedRequestWithPromise({ method, url, body, diff --git a/react/src/hooks/useSFTPResourceGroups.tsx b/react/src/hooks/useSFTPResourceGroups.tsx new file mode 100644 index 0000000000..f2ef97342b --- /dev/null +++ b/react/src/hooks/useSFTPResourceGroups.tsx @@ -0,0 +1,183 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { useBaiSignedRequestWithPromise } from '../helper'; +import { useTanQuery } from './reactQueryAlias'; +import { queryOptions, useQueryClient } from '@tanstack/react-query'; +import * as _ from 'lodash-es'; + +type BaiSignedRequest = ReturnType; + +const SFTP_PROXY_RESOURCE_GROUPS_QUERY_KEY = ['sftpProxyResourceGroups']; + +const PROXIES_ETCD_PREFIX = 'volumes/proxies'; + +const etcdKey = (proxy: string) => + `${PROXIES_ETCD_PREFIX}/${proxy}/sftp_scaling_groups`; + +const parseGroups = (value: string | null | undefined): string[] => + typeof value === 'string' + ? value + .split(',') + .map((group) => group.trim()) + .filter(Boolean) + : []; + +/** + * Read the `proxy -> resource group names` map straight from etcd + * (`/config/get` prefix on `volumes/proxies`). We read etcd — not + * `vfolder.list_hosts()` — because the manager caches `sftp_scaling_groups` + * at proxy-load time, so a fresh `/config/set` is not reflected by + * `list_hosts()` until the manager reloads. etcd is the store we write to, + * so this is immediately consistent after a write. Superadmin-only. + * + * The prefix read returns a nested tree keyed by proxy name, e.g. + * `{ local: { sftp_scaling_groups: "a,b", client_api: "...", ... }, ... }`. + */ +const getProxyResourceGroups = async ( + baiSignedRequest: BaiSignedRequest, +): Promise> => { + const res = await baiSignedRequest<{ + result?: Record< + string, + { sftp_scaling_groups?: string | null } | null + > | null; + }>({ + method: 'POST', + url: '/config/get', + body: { key: PROXIES_ETCD_PREFIX, prefix: true }, + }); + return _.mapValues(res?.result ?? {}, (proxyConfig) => + parseGroups(proxyConfig?.sftp_scaling_groups), + ); +}; + +/** + * Single source of truth for the proxy-resource-groups query (key + fetcher), + * shared by the suspending and non-suspending reader hooks below and by the + * write hook's cache invalidation. Superadmin-only (raw `/config/get`). + */ +const sftpProxyResourceGroupsQueryOptions = ( + baiSignedRequest: BaiSignedRequest, +) => + queryOptions({ + queryKey: SFTP_PROXY_RESOURCE_GROUPS_QUERY_KEY, + queryFn: () => getProxyResourceGroups(baiSignedRequest), + }); + +/** + * The storage proxies currently serving SFTP for any of `groupNames`, derived + * from the `proxy -> resource group names` map. Prefills the proxy multi-select + * and doubles as the baseline for the submit delta. A union across groups is + * correct even when the selected groups' assignments differ: deselecting a + * proxy is a per-proxy no-op for groups that weren't on it. + */ +export const proxiesServingGroups = ( + proxyResourceGroups: Record | undefined, + groupNames: string[], +): string[] => + _.keys( + _.pickBy( + proxyResourceGroups ?? {}, + (groups) => _.intersection(groups, groupNames).length > 0, + ), + ); + +/** + * The proxy-membership change from `initialProxies` to `targetProxies`: + * proxies newly selected (to add to) and deselected (to remove from). Callers + * need `removed` separately (e.g. to confirm before a destructive removal), + * so this stays a pure helper rather than being hidden inside the write. + */ +export const computeProxyDelta = ( + initialProxies: string[], + targetProxies: string[], +) => ({ + added: _.difference(targetProxies, initialProxies), + removed: _.difference(initialProxies, targetProxies), +}); + +/** + * Write helpers for the per-storage-proxy SFTP resource-group assignment that + * lives in etcd at `volumes/proxies/{proxy}/sftp_scaling_groups` (comma-joined). + * There is no GraphQL mutation for this key, so writes go through raw superadmin + * signed requests against `/config/{get,set}`. + * + * Every write reads the proxy's current list first and merges, because `set` + * overwrites the whole key — this preserves resource groups already assigned to + * the proxy that this operation isn't touching. + */ +export const useSFTPResourceGroups = () => { + 'use memo'; + const baiSignedRequest = useBaiSignedRequestWithPromise(); + const queryClient = useQueryClient(); + + const getGroupsForProxy = (proxy: string): Promise => + baiSignedRequest<{ result?: string | null }>({ + method: 'POST', + url: '/config/get', + body: { key: etcdKey(proxy), prefix: false }, + }).then((res) => parseGroups(res?.result)); + + const setGroupsForProxy = ( + proxy: string, + groups: string[], + ): Promise => + baiSignedRequest({ + method: 'POST', + url: '/config/set', + body: { key: etcdKey(proxy), value: groups.join(',') }, + }); + + /** + * Apply a proxy-membership delta to a set of groups at once: add every group + * in `groupNames` to `addedProxies` and remove them from `removedProxies`, + * preserving other groups on each affected proxy. `addedProxies` and + * `removedProxies` are disjoint by construction. Runs every proxy write + * concurrently and invalidates the cached map once. Shared write path for + * both the per-group and bulk SFTP settings flows. + */ + const applyGroupProxyDelta = async ( + groupNames: string[], + addedProxies: string[], + removedProxies: string[], + ) => { + const results = await Promise.allSettled([ + ...addedProxies.map(async (proxy) => { + const existing = await getGroupsForProxy(proxy); + await setGroupsForProxy(proxy, _.union(existing, groupNames)); + }), + ...removedProxies.map(async (proxy) => { + const existing = await getGroupsForProxy(proxy); + await setGroupsForProxy(proxy, _.difference(existing, groupNames)); + }), + ]); + queryClient.invalidateQueries({ + queryKey: SFTP_PROXY_RESOURCE_GROUPS_QUERY_KEY, + }); + return results; + }; + + return { + applyGroupProxyDelta, + }; +}; + +/** + * Non-suspending read of the `proxy -> resource group names` map. Used both by + * the resource-group list column and by the setting modals, which gate it on a + * deferred `open` and surface the fetch through their own loading skeletons + * rather than a Suspense boundary. Pass `enabled: false` for non-superadmins, + * who cannot read the raw etcd config. + */ +export const useSFTPProxyResourceGroupsQuery = (options?: { + enabled?: boolean; +}) => { + 'use memo'; + const baiSignedRequest = useBaiSignedRequestWithPromise(); + return useTanQuery({ + ...sftpProxyResourceGroupsQueryOptions(baiSignedRequest), + enabled: options?.enabled, + }); +}; diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 9977c664bd..144fc325ed 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -432,6 +432,7 @@ "PushToImage": "Push-Sitzung auf benutzerdefiniertes Bild", "Refresh": "Aktualisierung", "RefreshModelInformation": "Modellinformationen aktualisieren", + "Remove": "Entfernen", "Reset": "Zurücksetzen", "Retry": "Wiederholen", "Revoke": "Widerrufen", @@ -3213,6 +3214,18 @@ "UserFolderPermissions": "Benutzerordner-Berechtigungen" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Die ausgewählten Ressourcengruppen verwenden diese Speicher-Proxys nicht mehr für SFTP: {{proxies}}. Fortfahren?", + "ConfirmRemoveSFTPProxiesTitle": "SFTP-Zuweisungen entfernen?", + "FailedToUpdateSFTPResourceGroups": "Aktualisierung der SFTP-Ressourcengruppen fehlgeschlagen.", + "FollowingResourceGroupsWillBeConfigured": "Die folgenden Ressourcengruppen werden den ausgewählten Speicher-Proxys für SFTP zugewiesen:", + "SFTPResourceGroupSettings": "SFTP-Ressourcengruppeneinstellungen", + "SFTPResourceGroupSettingsDescription": "Der hier ausgewählte Speicher-Proxy übernimmt die SFTP-Sitzungen für die ausgewählten Ressourcengruppen.", + "SFTPResourceGroupsUpdated": "SFTP-Ressourcengruppen wurden aktualisiert.", + "SFTPStorageProxies": "SFTP-Speicher-Proxys", + "SFTPStorageProxiesDescription": "Speicher-Proxys, die SFTP-Sitzungen für diese Ressourcengruppe übernehmen.", + "StorageProxy": "Speicher-Proxy" + }, "summary": { "ATOMEnabled": "ATOM NPU Aktiviert", "ATOMMaxEnabled": "Atom Max NPU aktiviert", diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 4d8a5c86a9..511a6fd66b 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -432,6 +432,7 @@ "PushToImage": "Σπρώξτε τη συνεδρία σε προσαρμοσμένη εικόνα", "Refresh": "Φρεσκάρω", "RefreshModelInformation": "Ανανέωση πληροφοριών μοντέλου", + "Remove": "Αφαίρεση", "Reset": "Επαναφορά", "Retry": "Επανάληψη", "Revoke": "Ανάκληση", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Άδειες φακέλου χρήστη" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Οι επιλεγμένες ομάδες πόρων δεν θα χρησιμοποιούν πλέον αυτούς τους διακομιστές μεσολάβησης αποθήκευσης για SFTP: {{proxies}}. Συνέχεια;", + "ConfirmRemoveSFTPProxiesTitle": "Αφαίρεση εκχωρήσεων SFTP;", + "FailedToUpdateSFTPResourceGroups": "Αποτυχία ενημέρωσης ομάδων πόρων SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Οι ακόλουθες ομάδες πόρων θα εκχωρηθούν στους επιλεγμένους διακομιστές μεσολάβησης αποθήκευσης για SFTP:", + "SFTPResourceGroupSettings": "Ρυθμίσεις ομάδας πόρων SFTP", + "SFTPResourceGroupSettingsDescription": "Ο διακομιστής μεσολάβησης αποθήκευσης που επιλέγετε εδώ θα διαχειρίζεται τις συνεδρίες SFTP για τις επιλεγμένες ομάδες πόρων.", + "SFTPResourceGroupsUpdated": "Οι ομάδες πόρων SFTP ενημερώθηκαν.", + "SFTPStorageProxies": "Proxy αποθήκευσης SFTP", + "SFTPStorageProxiesDescription": "Proxy αποθήκευσης που διαχειρίζονται τις συνεδρίες SFTP για αυτήν την ομάδα πόρων.", + "StorageProxy": "Proxy αποθήκευσης" + }, "summary": { "ATOMEnabled": "ATOM NPU Ενεργοποιημένη", "ATOMMaxEnabled": "Atom Max NPU ενεργοποιημένη", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 9b3b94daa7..76579b6fef 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -419,6 +419,7 @@ "PushToImage": "Push session to customized image", "Refresh": "Refresh", "RefreshModelInformation": "Refresh Model Information", + "Remove": "Remove", "Reset": "Reset", "Retry": "Retry", "Revoke": "Revoke", @@ -3198,6 +3199,18 @@ "UserFolderPermissions": "User Folder Permissions" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "The selected resource groups will no longer use these storage proxies for SFTP: {{proxies}}. Continue?", + "ConfirmRemoveSFTPProxiesTitle": "Remove SFTP assignments?", + "FailedToUpdateSFTPResourceGroups": "Failed to update SFTP resource groups.", + "FollowingResourceGroupsWillBeConfigured": "The following resource groups will be assigned to the selected storage proxies for SFTP:", + "SFTPResourceGroupSettings": "SFTP Resource Group Settings", + "SFTPResourceGroupSettingsDescription": "The storage proxy you select here will handle SFTP sessions for the selected resource group(s).", + "SFTPResourceGroupsUpdated": "SFTP resource groups updated.", + "SFTPStorageProxies": "SFTP Storage Proxies", + "SFTPStorageProxiesDescription": "Storage proxies that handle SFTP sessions for this resource group.", + "StorageProxy": "Storage Proxy" + }, "summary": { "ATOMEnabled": "ATOM NPU Enabled", "ATOMMaxEnabled": "ATOM Max NPU Enabled", diff --git a/resources/i18n/es.json b/resources/i18n/es.json index d186e984c6..cf8c134f84 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -432,6 +432,7 @@ "PushToImage": "Enviar sesión a una imagen personalizada", "Refresh": "Actualizar", "RefreshModelInformation": "Actualizar información del modelo", + "Remove": "Quitar", "Reset": "Restablecer", "Retry": "Reintentar", "Revoke": "Revocar", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Permisos de carpeta de usuario" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Los grupos de recursos seleccionados ya no usarán estos proxies de almacenamiento para SFTP: {{proxies}}. ¿Continuar?", + "ConfirmRemoveSFTPProxiesTitle": "¿Quitar asignaciones de SFTP?", + "FailedToUpdateSFTPResourceGroups": "Error al actualizar los grupos de recursos SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Los siguientes grupos de recursos se asignarán a los proxies de almacenamiento seleccionados para SFTP:", + "SFTPResourceGroupSettings": "Configuración de grupos de recursos SFTP", + "SFTPResourceGroupSettingsDescription": "El proxy de almacenamiento que seleccione aquí gestionará las sesiones SFTP de los grupos de recursos seleccionados.", + "SFTPResourceGroupsUpdated": "Grupos de recursos SFTP actualizados.", + "SFTPStorageProxies": "Proxies de almacenamiento SFTP", + "SFTPStorageProxiesDescription": "Proxies de almacenamiento que gestionan las sesiones SFTP de este grupo de recursos.", + "StorageProxy": "Proxy de almacenamiento" + }, "summary": { "ATOMEnabled": "NPU ATOM Activada", "ATOMMaxEnabled": "Atom max npu habilitado", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 5ffea87a32..b4d0830128 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -432,6 +432,7 @@ "PushToImage": "Työnnä istunto mukautettuun kuvaan", "Refresh": "Päivitä", "RefreshModelInformation": "Päivitä mallin tiedot", + "Remove": "Poista", "Reset": "Nollaa", "Retry": "Yritä uudelleen", "Revoke": "Peruuta", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Käyttäjäkansion oikeudet" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Valitut resurssiryhmät eivät enää käytä näitä tallennusvälityspalvelimia SFTP:tä varten: {{proxies}}. Jatketaanko?", + "ConfirmRemoveSFTPProxiesTitle": "Poistetaanko SFTP-määritykset?", + "FailedToUpdateSFTPResourceGroups": "SFTP-resurssiryhmien päivittäminen epäonnistui.", + "FollowingResourceGroupsWillBeConfigured": "Seuraavat resurssiryhmät määritetään valituille tallennusvälityspalvelimille SFTP:tä varten:", + "SFTPResourceGroupSettings": "SFTP-resurssiryhmän asetukset", + "SFTPResourceGroupSettingsDescription": "Tässä valitsemasi tallennusvälityspalvelin käsittelee valittujen resurssiryhmien SFTP-istunnot.", + "SFTPResourceGroupsUpdated": "SFTP-resurssiryhmät päivitetty.", + "SFTPStorageProxies": "SFTP-tallennusvälityspalvelimet", + "SFTPStorageProxiesDescription": "Tallennusvälityspalvelimet, jotka käsittelevät tämän resurssiryhmän SFTP-istunnot.", + "StorageProxy": "Tallennusvälityspalvelin" + }, "summary": { "ATOMEnabled": "ATOM NPU käytössä", "ATOMMaxEnabled": "Atom Max NPU käytössä", diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index b9e2a36b9a..ee5057b1fe 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -432,6 +432,7 @@ "PushToImage": "Pousser la session vers une image personnalisée", "Refresh": "Rafraîchir", "RefreshModelInformation": "Actualiser les informations sur le modèle", + "Remove": "Supprimer", "Reset": "Réinitialisation", "Retry": "Réessayer", "Revoke": "Révoquer", @@ -3214,6 +3215,18 @@ "UserFolderPermissions": "Autorisations des dossiers utilisateur" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Les groupes de ressources sélectionnés n'utiliseront plus ces proxies de stockage pour le SFTP : {{proxies}}. Continuer ?", + "ConfirmRemoveSFTPProxiesTitle": "Retirer les affectations SFTP ?", + "FailedToUpdateSFTPResourceGroups": "Échec de la mise à jour des groupes de ressources SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Les groupes de ressources suivants seront affectés aux proxies de stockage sélectionnés pour le SFTP :", + "SFTPResourceGroupSettings": "Paramètres des groupes de ressources SFTP", + "SFTPResourceGroupSettingsDescription": "Le proxy de stockage que vous sélectionnez ici gérera les sessions SFTP des groupes de ressources sélectionnés.", + "SFTPResourceGroupsUpdated": "Groupes de ressources SFTP mis à jour.", + "SFTPStorageProxies": "Proxies de stockage SFTP", + "SFTPStorageProxiesDescription": "Proxies de stockage qui gèrent les sessions SFTP de ce groupe de ressources.", + "StorageProxy": "Proxy de stockage" + }, "summary": { "ATOMEnabled": "ATOM NPU Activé", "ATOMMaxEnabled": "ATOM MAX NPU Activé", diff --git a/resources/i18n/id.json b/resources/i18n/id.json index 2176f97d01..44f75a1dd1 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -432,6 +432,7 @@ "PushToImage": "Sesi dorong ke gambar yang disesuaikan", "Refresh": "Segarkan", "RefreshModelInformation": "Menyegarkan Informasi Model", + "Remove": "Hapus", "Reset": "Atur Ulang", "Retry": "Coba lagi", "Revoke": "Cabut", @@ -3215,6 +3216,18 @@ "UserFolderPermissions": "Izin Folder Pengguna" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Grup sumber daya yang dipilih tidak akan lagi menggunakan proksi penyimpanan ini untuk SFTP: {{proxies}}. Lanjutkan?", + "ConfirmRemoveSFTPProxiesTitle": "Hapus penetapan SFTP?", + "FailedToUpdateSFTPResourceGroups": "Gagal memperbarui grup sumber daya SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Grup sumber daya berikut akan ditetapkan ke proksi penyimpanan yang dipilih untuk SFTP:", + "SFTPResourceGroupSettings": "Pengaturan Grup Sumber Daya SFTP", + "SFTPResourceGroupSettingsDescription": "Proksi penyimpanan yang Anda pilih di sini akan menangani sesi SFTP untuk grup sumber daya yang dipilih.", + "SFTPResourceGroupsUpdated": "Grup sumber daya SFTP telah diperbarui.", + "SFTPStorageProxies": "Proksi Penyimpanan SFTP", + "SFTPStorageProxiesDescription": "Proksi penyimpanan yang menangani sesi SFTP untuk grup sumber daya ini.", + "StorageProxy": "Proksi Penyimpanan" + }, "summary": { "ATOMEnabled": "ATOM NPU Diaktifkan", "ATOMMaxEnabled": "Atom max npu diaktifkan", diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 5164ec0f57..0fabd5f802 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -432,6 +432,7 @@ "PushToImage": "Push della sessione all'immagine personalizzata", "Refresh": "ricaricare", "RefreshModelInformation": "Aggiornare le informazioni sul modello", + "Remove": "Rimuovi", "Reset": "Reset", "Retry": "Riprova", "Revoke": "Revoca", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Autorizzazioni cartella utente" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "I gruppi di risorse selezionati non utilizzeranno più questi proxy di archiviazione per SFTP: {{proxies}}. Continuare?", + "ConfirmRemoveSFTPProxiesTitle": "Rimuovere le assegnazioni SFTP?", + "FailedToUpdateSFTPResourceGroups": "Impossibile aggiornare i gruppi di risorse SFTP.", + "FollowingResourceGroupsWillBeConfigured": "I seguenti gruppi di risorse verranno assegnati ai proxy di archiviazione selezionati per SFTP:", + "SFTPResourceGroupSettings": "Impostazioni dei gruppi di risorse SFTP", + "SFTPResourceGroupSettingsDescription": "Il proxy di archiviazione selezionato qui gestirà le sessioni SFTP per i gruppi di risorse selezionati.", + "SFTPResourceGroupsUpdated": "Gruppi di risorse SFTP aggiornati.", + "SFTPStorageProxies": "Proxy di archiviazione SFTP", + "SFTPStorageProxiesDescription": "Proxy di archiviazione che gestiscono le sessioni SFTP per questo gruppo di risorse.", + "StorageProxy": "Proxy di archiviazione" + }, "summary": { "ATOMEnabled": "NPU ATOM abilitata", "ATOMMaxEnabled": "Atom Max NPU abilitato", diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json index ef7745af29..17bc42933d 100644 --- a/resources/i18n/ja.json +++ b/resources/i18n/ja.json @@ -432,6 +432,7 @@ "PushToImage": "セッションをカスタマイズされたイメージにプッシュする", "Refresh": "更新", "RefreshModelInformation": "モデル情報の更新", + "Remove": "削除", "Reset": "初期化", "Retry": "再試行", "Revoke": "無効化", @@ -3213,6 +3214,18 @@ "UserFolderPermissions": "ユーザーフォルダーの権限" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "選択したリソースグループは、これらのストレージプロキシをSFTP用として使用しなくなります: {{proxies}}。続行しますか?", + "ConfirmRemoveSFTPProxiesTitle": "SFTPの割り当てを削除しますか?", + "FailedToUpdateSFTPResourceGroups": "SFTPリソースグループの更新に失敗しました。", + "FollowingResourceGroupsWillBeConfigured": "以下のリソースグループが、選択したストレージプロキシにSFTP用として割り当てられます:", + "SFTPResourceGroupSettings": "SFTPリソースグループ設定", + "SFTPResourceGroupSettingsDescription": "ここで選択したストレージプロキシが、選択したリソースグループのSFTPセッションを処理します。", + "SFTPResourceGroupsUpdated": "SFTPリソースグループが更新されました。", + "SFTPStorageProxies": "SFTPストレージプロキシ", + "SFTPStorageProxiesDescription": "このリソースグループのSFTPセッションを処理するストレージプロキシです。", + "StorageProxy": "ストレージプロキシ" + }, "summary": { "ATOMEnabled": "ATOM NPU使用中", "ATOMMaxEnabled": "Atom Max NPUが有効になっています", diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index c83f4caa7f..0073e7aa9f 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -432,6 +432,7 @@ "PushToImage": "세션을 사용자 정의된 이미지로 푸시", "Refresh": "새로고침", "RefreshModelInformation": "모델 정보 새로고침", + "Remove": "제거", "Reset": "초기화", "Retry": "재시도", "Revoke": "해지", @@ -3214,6 +3215,18 @@ "UserFolderPermissions": "사용자 폴더 권한" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "선택한 자원 그룹이 다음 스토리지 프록시에서 SFTP용으로 더 이상 사용되지 않습니다: {{proxies}}. 계속하시겠습니까?", + "ConfirmRemoveSFTPProxiesTitle": "SFTP 배정을 해제할까요?", + "FailedToUpdateSFTPResourceGroups": "SFTP 자원 그룹 업데이트에 실패했습니다.", + "FollowingResourceGroupsWillBeConfigured": "다음 자원 그룹이 선택한 스토리지 프록시에 SFTP용으로 배정됩니다:", + "SFTPResourceGroupSettings": "SFTP 자원 그룹 설정", + "SFTPResourceGroupSettingsDescription": "여기서 선택한 스토리지 프록시가 선택된 자원 그룹의 SFTP 세션을 처리합니다.", + "SFTPResourceGroupsUpdated": "SFTP 자원 그룹이 업데이트되었습니다.", + "SFTPStorageProxies": "SFTP 스토리지 프록시", + "SFTPStorageProxiesDescription": "이 자원 그룹의 SFTP 세션을 처리할 스토리지 프록시입니다.", + "StorageProxy": "스토리지 프록시" + }, "summary": { "ATOMEnabled": "ATOM NPU 사용 중", "ATOMMaxEnabled": "ATOM Max 지원", diff --git a/resources/i18n/mn.json b/resources/i18n/mn.json index 7b1a8a787d..f3fd738d98 100644 --- a/resources/i18n/mn.json +++ b/resources/i18n/mn.json @@ -432,6 +432,7 @@ "PushToImage": "Тохируулсан зураг руу түлхэх сесс", "Refresh": "Сэргээх", "RefreshModelInformation": "Загварын мэдээллийг шинэчлэх", + "Remove": "Хасах", "Reset": "Дахин тохируулах", "Retry": "Дахин оролдох", "Revoke": "Цуцлах", @@ -3213,6 +3214,18 @@ "UserFolderPermissions": "Хэрэглэгчийн хавтасны зөвшөөрлүүд" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Сонгосон нөөцийн бүлгүүд эдгээр хадгалалтын проксийг SFTP-д цаашид ашиглахгүй болно: {{proxies}}. Үргэлжлүүлэх үү?", + "ConfirmRemoveSFTPProxiesTitle": "SFTP оноолтыг хасах уу?", + "FailedToUpdateSFTPResourceGroups": "SFTP нөөцийн бүлгүүдийг шинэчлэхэд алдаа гарлаа.", + "FollowingResourceGroupsWillBeConfigured": "Дараах нөөцийн бүлгүүд нь сонгосон хадгалалтын проксид SFTP-д зориулан оноогдоно:", + "SFTPResourceGroupSettings": "SFTP нөөцийн бүлгийн тохиргоо", + "SFTPResourceGroupSettingsDescription": "Энд сонгосон хадгалалтын прокси нь сонгосон нөөцийн бүлгүүдийн SFTP сессүүдийг боловсруулна.", + "SFTPResourceGroupsUpdated": "SFTP нөөцийн бүлгүүд шинэчлэгдлээ.", + "SFTPStorageProxies": "SFTP хадгалалтын прокси", + "SFTPStorageProxiesDescription": "Энэ нөөцийн бүлгийн SFTP сессүүдийг боловсруулдаг хадгалалтын прокси.", + "StorageProxy": "Хадгалалтын прокси" + }, "summary": { "ATOMEnabled": "ATOM NPU-г идэвхжүүлсэн", "ATOMMaxEnabled": "Атомын Max NPU идэвхжсэн", diff --git a/resources/i18n/ms.json b/resources/i18n/ms.json index 4090c55de1..2303b6b726 100644 --- a/resources/i18n/ms.json +++ b/resources/i18n/ms.json @@ -432,6 +432,7 @@ "PushToImage": "Sesi tolak ke imej tersuai", "Refresh": "Segarkan", "RefreshModelInformation": "Refresh Model Maklumat", + "Remove": "Buang", "Reset": "Tetapkan semula", "Retry": "Cuba semula", "Revoke": "Batalkan", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Kebenaran Folder Pengguna" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Kumpulan sumber yang dipilih tidak akan lagi menggunakan proksi storan ini untuk SFTP: {{proxies}}. Teruskan?", + "ConfirmRemoveSFTPProxiesTitle": "Buang pemberian SFTP?", + "FailedToUpdateSFTPResourceGroups": "Gagal mengemas kini kumpulan sumber SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Kumpulan sumber berikut akan diberikan kepada proksi storan yang dipilih untuk SFTP:", + "SFTPResourceGroupSettings": "Tetapan Kumpulan Sumber SFTP", + "SFTPResourceGroupSettingsDescription": "Proksi storan yang anda pilih di sini akan mengendalikan sesi SFTP untuk kumpulan sumber yang dipilih.", + "SFTPResourceGroupsUpdated": "Kumpulan sumber SFTP telah dikemas kini.", + "SFTPStorageProxies": "Proksi Storan SFTP", + "SFTPStorageProxiesDescription": "Proksi storan yang mengendalikan sesi SFTP untuk kumpulan sumber ini.", + "StorageProxy": "Proksi Storan" + }, "summary": { "ATOMEnabled": "ATOM NPU Didayakan", "ATOMMaxEnabled": "Atom Max NPU diaktifkan", diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index 4581ee5dd3..d55587db98 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -432,6 +432,7 @@ "PushToImage": "Prześlij sesję do spersonalizowanego obrazu", "Refresh": "Odświeżać", "RefreshModelInformation": "Odświeżanie informacji o modelu", + "Remove": "Usuń", "Reset": "Reset", "Retry": "Ponów", "Revoke": "Odwołaj", @@ -3214,6 +3215,18 @@ "UserFolderPermissions": "Uprawnienia folderu użytkownika" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Wybrane grupy zasobów przestaną używać tych serwerów proxy pamięci masowej do SFTP: {{proxies}}. Kontynuować?", + "ConfirmRemoveSFTPProxiesTitle": "Usunąć przypisania SFTP?", + "FailedToUpdateSFTPResourceGroups": "Nie udało się zaktualizować grup zasobów SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Następujące grupy zasobów zostaną przypisane do wybranych serwerów proxy pamięci masowej na potrzeby SFTP:", + "SFTPResourceGroupSettings": "Ustawienia grup zasobów SFTP", + "SFTPResourceGroupSettingsDescription": "Serwer proxy pamięci masowej wybrany tutaj będzie obsługiwał sesje SFTP dla wybranych grup zasobów.", + "SFTPResourceGroupsUpdated": "Grupy zasobów SFTP zostały zaktualizowane.", + "SFTPStorageProxies": "Serwery proxy pamięci masowej SFTP", + "SFTPStorageProxiesDescription": "Serwery proxy pamięci masowej, które obsługują sesje SFTP dla tej grupy zasobów.", + "StorageProxy": "Serwer proxy pamięci masowej" + }, "summary": { "ATOMEnabled": "ATOM NPU Enabled", "ATOMMaxEnabled": "Atom Max NPU włączony", diff --git a/resources/i18n/pt-BR.json b/resources/i18n/pt-BR.json index 55a569399f..fdb136cd0c 100644 --- a/resources/i18n/pt-BR.json +++ b/resources/i18n/pt-BR.json @@ -432,6 +432,7 @@ "PushToImage": "Enviar sessão para imagem personalizada", "Refresh": "Atualizar", "RefreshModelInformation": "Atualizar informações sobre o modelo", + "Remove": "Remover", "Reset": "Reiniciar", "Retry": "Tentar novamente", "Revoke": "Revogar", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Permissões de pasta de usuário" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Os grupos de recursos selecionados deixarão de usar esses proxies de armazenamento para SFTP: {{proxies}}. Continuar?", + "ConfirmRemoveSFTPProxiesTitle": "Remover atribuições de SFTP?", + "FailedToUpdateSFTPResourceGroups": "Falha ao atualizar os grupos de recursos SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Os seguintes grupos de recursos serão atribuídos aos proxies de armazenamento selecionados para SFTP:", + "SFTPResourceGroupSettings": "Configurações do Grupo de Recursos SFTP", + "SFTPResourceGroupSettingsDescription": "O proxy de armazenamento selecionado aqui gerenciará as sessões SFTP dos grupos de recursos selecionados.", + "SFTPResourceGroupsUpdated": "Grupos de recursos SFTP atualizados.", + "SFTPStorageProxies": "Proxies de armazenamento SFTP", + "SFTPStorageProxiesDescription": "Proxies de armazenamento que gerenciam as sessões SFTP deste grupo de recursos.", + "StorageProxy": "Proxy de armazenamento" + }, "summary": { "ATOMEnabled": "ATOM NPU Ativado", "ATOMMaxEnabled": "Atom Max NPU habilitado", diff --git a/resources/i18n/pt.json b/resources/i18n/pt.json index a1d8de874a..fe19a9e921 100644 --- a/resources/i18n/pt.json +++ b/resources/i18n/pt.json @@ -432,6 +432,7 @@ "PushToImage": "Enviar sessão para imagem personalizada", "Refresh": "Atualizar", "RefreshModelInformation": "Atualizar informações sobre o modelo", + "Remove": "Remover", "Reset": "Reiniciar", "Retry": "Tentar novamente", "Revoke": "Revogar", @@ -3213,6 +3214,18 @@ "UserFolderPermissions": "Permissões de pasta de utilizador" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Os grupos de recursos selecionados deixarão de utilizar estes proxies de armazenamento para SFTP: {{proxies}}. Continuar?", + "ConfirmRemoveSFTPProxiesTitle": "Remover atribuições de SFTP?", + "FailedToUpdateSFTPResourceGroups": "Falha ao atualizar os grupos de recursos SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Os seguintes grupos de recursos serão atribuídos aos proxies de armazenamento selecionados para SFTP:", + "SFTPResourceGroupSettings": "Definições do Grupo de Recursos SFTP", + "SFTPResourceGroupSettingsDescription": "O proxy de armazenamento selecionado aqui irá gerir as sessões SFTP dos grupos de recursos selecionados.", + "SFTPResourceGroupsUpdated": "Grupos de recursos SFTP atualizados.", + "SFTPStorageProxies": "Proxies de armazenamento SFTP", + "SFTPStorageProxiesDescription": "Proxies de armazenamento que gerem as sessões SFTP deste grupo de recursos.", + "StorageProxy": "Proxy de armazenamento" + }, "summary": { "ATOMEnabled": "ATOM NPU Ativado", "ATOMMaxEnabled": "Atom Max NPU habilitado", diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index fb2637b71d..fc23413555 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -432,6 +432,7 @@ "PushToImage": "Отправить сеанс на настроенное изображение", "Refresh": "Обновить", "RefreshModelInformation": "Обновить информацию о модели", + "Remove": "Удалить", "Reset": "Перезагрузить", "Retry": "Повторить", "Revoke": "Отозвать", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Права доступа к папкам пользователя" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Выбранные группы ресурсов больше не будут использовать эти прокси хранилища для SFTP: {{proxies}}. Продолжить?", + "ConfirmRemoveSFTPProxiesTitle": "Удалить назначения SFTP?", + "FailedToUpdateSFTPResourceGroups": "Не удалось обновить группы ресурсов SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Следующие группы ресурсов будут назначены выбранным прокси хранилища для SFTP:", + "SFTPResourceGroupSettings": "Настройки групп ресурсов SFTP", + "SFTPResourceGroupSettingsDescription": "Прокси хранилища, который вы выберете здесь, будет обрабатывать SFTP-сеансы для выбранных групп ресурсов.", + "SFTPResourceGroupsUpdated": "Группы ресурсов SFTP обновлены.", + "SFTPStorageProxies": "Прокси хранилища SFTP", + "SFTPStorageProxiesDescription": "Прокси хранилища, которые обрабатывают SFTP-сеансы для этой группы ресурсов.", + "StorageProxy": "Прокси хранилища" + }, "summary": { "ATOMEnabled": "ATOM NPU Включено", "ATOMMaxEnabled": "Atom Max NPU включен", diff --git a/resources/i18n/th.json b/resources/i18n/th.json index d6e7159a3e..e3703a8c24 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -432,6 +432,7 @@ "PushToImage": "ผลักดันเซสชันไปยังอิมเมจที่กำหนดเอง", "Refresh": "รีเฟรช", "RefreshModelInformation": "รีเฟรชข้อมูลโมเดล", + "Remove": "ลบ", "Reset": "รีเซ็ต", "Retry": "ลองใหม่", "Revoke": "เพิกถอน", @@ -3213,6 +3214,18 @@ "UserFolderPermissions": "สิทธิ์โฟลเดอร์ผู้ใช้" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "กลุ่มทรัพยากรที่เลือกจะไม่ใช้พร็อกซีที่เก็บข้อมูลเหล่านี้สำหรับ SFTP อีกต่อไป: {{proxies}} ดำเนินการต่อหรือไม่?", + "ConfirmRemoveSFTPProxiesTitle": "นำการกำหนด SFTP ออกหรือไม่?", + "FailedToUpdateSFTPResourceGroups": "ไม่สามารถอัปเดตกลุ่มทรัพยากร SFTP ได้", + "FollowingResourceGroupsWillBeConfigured": "กลุ่มทรัพยากรต่อไปนี้จะถูกกำหนดให้กับพร็อกซีที่เก็บข้อมูลที่เลือกสำหรับ SFTP:", + "SFTPResourceGroupSettings": "การตั้งค่ากลุ่มทรัพยากร SFTP", + "SFTPResourceGroupSettingsDescription": "พร็อกซีที่เก็บข้อมูลที่คุณเลือกที่นี่จะจัดการเซสชัน SFTP สำหรับกลุ่มทรัพยากรที่เลือก", + "SFTPResourceGroupsUpdated": "อัปเดตกลุ่มทรัพยากร SFTP เรียบร้อยแล้ว", + "SFTPStorageProxies": "พร็อกซีที่เก็บข้อมูล SFTP", + "SFTPStorageProxiesDescription": "พร็อกซีที่เก็บข้อมูลที่จัดการเซสชัน SFTP สำหรับกลุ่มทรัพยากรนี้", + "StorageProxy": "พร็อกซีที่เก็บข้อมูล" + }, "summary": { "ATOMEnabled": "เปิดใช้งาน ATOM NPU", "ATOMMaxEnabled": "เปิดใช้งาน Atom Max NPU", diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index 2aa6a15a51..c60626237c 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -432,6 +432,7 @@ "PushToImage": "Oturumu özelleştirilmiş resme aktarın", "Refresh": "Yenile", "RefreshModelInformation": "Model Bilgilerini Yenile", + "Remove": "Kaldır", "Reset": "Sıfırla", "Retry": "Yeniden Dene", "Revoke": "İptal Et", @@ -3211,6 +3212,18 @@ "UserFolderPermissions": "Kullanıcı Klasörü İzinleri" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Seçili kaynak grupları bu depolama proxy'lerini artık SFTP için kullanmayacak: {{proxies}}. Devam edilsin mi?", + "ConfirmRemoveSFTPProxiesTitle": "SFTP atamaları kaldırılsın mı?", + "FailedToUpdateSFTPResourceGroups": "SFTP kaynak grupları güncellenemedi.", + "FollowingResourceGroupsWillBeConfigured": "Aşağıdaki kaynak grupları, SFTP için seçilen depolama proxy'lerine atanacaktır:", + "SFTPResourceGroupSettings": "SFTP Kaynak Grubu Ayarları", + "SFTPResourceGroupSettingsDescription": "Burada seçtiğiniz depolama proxy'si, seçilen kaynak grupları için SFTP oturumlarını işleyecektir.", + "SFTPResourceGroupsUpdated": "SFTP kaynak grupları güncellendi.", + "SFTPStorageProxies": "SFTP Depolama Proxy'leri", + "SFTPStorageProxiesDescription": "Bu kaynak grubu için SFTP oturumlarını işleyen depolama proxy'leri.", + "StorageProxy": "Depolama Proxy'si" + }, "summary": { "ATOMEnabled": "ATOM NPU Etkin", "ATOMMaxEnabled": "Atom Max NPU etkin", diff --git a/resources/i18n/vi.json b/resources/i18n/vi.json index 87f00b6607..8b915c2602 100644 --- a/resources/i18n/vi.json +++ b/resources/i18n/vi.json @@ -432,6 +432,7 @@ "PushToImage": "Đẩy phiên sang hình ảnh tùy chỉnh", "Refresh": "Làm tươi", "RefreshModelInformation": "Làm mới thông tin mô hình", + "Remove": "Xóa", "Reset": "Cài lại", "Retry": "Thử lại", "Revoke": "Thu hồi", @@ -3213,6 +3214,18 @@ "UserFolderPermissions": "Quyền thư mục người dùng" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "Các nhóm tài nguyên đã chọn sẽ không còn sử dụng các proxy lưu trữ này cho SFTP: {{proxies}}. Tiếp tục?", + "ConfirmRemoveSFTPProxiesTitle": "Xóa phân công SFTP?", + "FailedToUpdateSFTPResourceGroups": "Không thể cập nhật nhóm tài nguyên SFTP.", + "FollowingResourceGroupsWillBeConfigured": "Các nhóm tài nguyên sau đây sẽ được gán cho các proxy lưu trữ đã chọn cho SFTP:", + "SFTPResourceGroupSettings": "Cài đặt nhóm tài nguyên SFTP", + "SFTPResourceGroupSettingsDescription": "Proxy lưu trữ bạn chọn ở đây sẽ xử lý các phiên SFTP cho (các) nhóm tài nguyên đã chọn.", + "SFTPResourceGroupsUpdated": "Đã cập nhật nhóm tài nguyên SFTP.", + "SFTPStorageProxies": "Proxy lưu trữ SFTP", + "SFTPStorageProxiesDescription": "Proxy lưu trữ xử lý các phiên SFTP cho nhóm tài nguyên này.", + "StorageProxy": "Proxy lưu trữ" + }, "summary": { "ATOMEnabled": "Đã bật NPU ATOM", "ATOMMaxEnabled": "Atom Max NPU được bật", diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json index c7b0b18980..c3d6272936 100644 --- a/resources/i18n/zh-CN.json +++ b/resources/i18n/zh-CN.json @@ -432,6 +432,7 @@ "PushToImage": "将会话推送到自定义镜像", "Refresh": "刷新", "RefreshModelInformation": "刷新机型信息", + "Remove": "移除", "Reset": "重置", "Retry": "重试", "Revoke": "撤销", @@ -3213,6 +3214,18 @@ "UserFolderPermissions": "用户文件夹权限" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "所选资源组将不再使用以下存储代理进行 SFTP: {{proxies}}。是否继续?", + "ConfirmRemoveSFTPProxiesTitle": "移除 SFTP 分配?", + "FailedToUpdateSFTPResourceGroups": "更新 SFTP 资源组失败。", + "FollowingResourceGroupsWillBeConfigured": "以下资源组将被分配给所选存储代理以用于 SFTP:", + "SFTPResourceGroupSettings": "SFTP 资源组设置", + "SFTPResourceGroupSettingsDescription": "您在此处选择的存储代理将处理所选资源组的 SFTP 会话。", + "SFTPResourceGroupsUpdated": "SFTP 资源组已更新。", + "SFTPStorageProxies": "SFTP 存储代理", + "SFTPStorageProxiesDescription": "处理此资源组 SFTP 会话的存储代理。", + "StorageProxy": "存储代理" + }, "summary": { "ATOMEnabled": "已启用 ATOM NPU", "ATOMMaxEnabled": "Atom Max NPU启用", diff --git a/resources/i18n/zh-TW.json b/resources/i18n/zh-TW.json index 4b1468b6a6..67848f58c6 100644 --- a/resources/i18n/zh-TW.json +++ b/resources/i18n/zh-TW.json @@ -432,6 +432,7 @@ "PushToImage": "將會話推送到自訂鏡像", "Refresh": "刷新", "RefreshModelInformation": "刷新机型信息", + "Remove": "移除", "Reset": "重置", "Retry": "重試", "Revoke": "撤銷", @@ -3215,6 +3216,18 @@ "UserFolderPermissions": "使用者資料夾權限" } }, + "storageProxy": { + "ConfirmRemoveSFTPProxiesContent": "所選資源群組將不再使用以下儲存代理進行 SFTP: {{proxies}}。是否繼續?", + "ConfirmRemoveSFTPProxiesTitle": "移除 SFTP 指派?", + "FailedToUpdateSFTPResourceGroups": "更新 SFTP 資源群組失敗。", + "FollowingResourceGroupsWillBeConfigured": "以下資源群組將被指派給所選儲存代理以用於 SFTP:", + "SFTPResourceGroupSettings": "SFTP 資源群組設定", + "SFTPResourceGroupSettingsDescription": "您在此處選擇的儲存代理將處理所選資源群組的 SFTP 工作階段。", + "SFTPResourceGroupsUpdated": "SFTP 資源群組已更新。", + "SFTPStorageProxies": "SFTP 儲存代理", + "SFTPStorageProxiesDescription": "處理此資源群組 SFTP 工作階段的儲存代理。", + "StorageProxy": "儲存代理" + }, "summary": { "ATOMEnabled": "已启用 ATOM NPU", "ATOMMaxEnabled": "Atom Max NPU啟用",