feat(FR-2969): migrate storage host permission management to detail drawer#7587
Conversation
There was a problem hiding this comment.
Pull request overview
Consolidates storage host quota and permission management into the Backend.AI WebUI “Resources → Storage” flow by replacing the legacy /storage-settings/:hostname page with a tabbed detail drawer opened from the storage list’s gear action, and ports storage host permission management from control-panel into WebUI using native Relay queries/mutations.
Changes:
- Introduces
StorageHostDetailDrawer(+ content) and wires the storage list gear icon to open it (with refresh viaBAIFetchKeyButton). - Adds a superadmin-only “Permissions” tab (
StorageHostPermissionPanel) that editsallowed_vfolder_hostsfor domains/groups/keypair resource policies via Relay mutations. - Removes the standalone settings page and redirects
/storage-settings/:hostnameto/agent, cleaning up sidebar/menu selection logic and adding i18n keys (en/ko).
Reviewed changes
Copilot reviewed 16 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| resources/i18n/en.json | Adds i18n keys for new drawer tabs and permission UI strings (English). |
| resources/i18n/ko.json | Adds i18n keys for new drawer tabs and permission UI strings (Korean). |
| react/src/routes.tsx | Removes the settings page route component and redirects /storage-settings/:hostname to /agent. |
| react/src/pages/StorageHostSettingPage.tsx | Deletes the legacy standalone storage host settings page. |
| react/src/hooks/useWebUIMenuItems.tsx | Removes storage-settings special-casing for menu selection logic. |
| react/src/components/MainLayout/WebUISider.tsx | Removes storage-settings special-casing for sider selection. |
| react/src/components/StorageProxyList.tsx | Replaces navigation to /storage-settings/... with opening the new detail drawer. |
| react/src/components/StorageHostDetailDrawer.tsx | New drawer shell with refresh button and Suspense-based content loading. |
| react/src/components/StorageHostDetailDrawerContent.tsx | New tabbed drawer content: resource panel + quota tab + superadmin-only permissions tab. |
| react/src/components/StorageHostPermissionPanel.tsx | New permission matrix editor for domains/projects/KRPs with Relay queries + mutations and save flow. |
| react/src/generated/StorageHostDetailDrawerContentQuery.graphql.ts | Relay artifact for the new drawer content query (replacing deleted page query). |
| react/src/generated/StorageHostPermissionPanelHostPermissionsQuery.graphql.ts | Relay artifact for fetching canonical permission keys. |
| react/src/generated/StorageHostPermissionPanelPoliciesQuery.graphql.ts | Relay artifact for fetching domains/groups/KRPs permission JSON strings. |
| react/src/generated/StorageHostPermissionPanelModifyDomainMutation.graphql.ts | Relay artifact for saving domain permission changes. |
| react/src/generated/StorageHostPermissionPanelModifyGroupMutation.graphql.ts | Relay artifact for saving group permission changes. |
| react/src/generated/StorageHostPermissionPanelModifyKeypairResourcePolicyMutation.graphql.ts | Relay artifact for saving KRP permission changes. |
| .specs/FR-2969-storage-host-permission-migration/spec.md | Adds implementation spec for the migration and UX target behavior. |
| .specs/FR-2969-storage-host-permission-migration/dev-plan.md | Adds dev plan documenting phase breakdown and verification steps. |
| .specs/FR-2969-storage-host-permission-migration/metadata.json | Adds spec metadata (epic/subtasks). |
| .specs/FR-2969-storage-host-permission-migration/.context/tasks.md | Adds task tracking context. |
| .specs/FR-2969-storage-host-permission-migration/.context/progress.md | Adds progress tracking context. |
Files not reviewed (5)
- react/src/generated/StorageHostPermissionPanelHostPermissionsQuery.graphql.ts: Language not supported
- react/src/generated/StorageHostPermissionPanelModifyDomainMutation.graphql.ts: Language not supported
- react/src/generated/StorageHostPermissionPanelModifyGroupMutation.graphql.ts: Language not supported
- react/src/generated/StorageHostPermissionPanelModifyKeypairResourcePolicyMutation.graphql.ts: Language not supported
- react/src/generated/StorageHostPermissionPanelPoliciesQuery.graphql.ts: Language not supported
Verify Spec ResultsSpec:
Spec compliance: 26/27 (96%) The 1 PARTIAL
Relay / Lint / Format / Vite all PASS. TypeScript FAILs on 15 pre-existing baseline errors in unrelated files ( SecurityCRITICAL 0 / HIGH 0 / MEDIUM 0 / LOW 2 (already noted by pr-reviewer in batch-review). JSON merge preservation, defensive Type B additions (impl beyond spec)These came out of agent reviews and are improvements over the spec's literal requirements:
No spec updates needed — these are implementation details below the spec's abstraction level. Follow-up issuesNone. Every Must-Have acceptance criterion is addressed. VerdictPASS WITH NOTES — ready for human review and merge. 🤖 Generated by /fw:pipeline verify-spec stage |
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has required the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
Coverage Report for backend-ai-ui-coverage (./packages/backend.ai-ui)
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
… 10) Resolves #7625 (FR-2991) — stacked on FR-2969 (#7587). Each of the three permission cards in the Storage Host detail drawer now accepts multiple entities. Selected entities become rows in the card's table. The previous single-entity selection is the N=1 case of the same flow — name column / host-allowed status / permission icons / per-row Edit action all preserved. ## Selection cap (10 per card) - The select is `mode="multiple"` with `maxTagCount="responsive"`. - Selecting an 11th item flips the select to `status="error"` (red border) and renders a small inline `Typography.Text type="danger"` below the select: "최대 10개까지 선택할 수 있습니다." - The panel still clamps the array it passes to the table to the first 10 entries, so even if the error is ignored, the network/render stay bounded. ## Per-table data fetching changes | Table | Before | After | |---|---|---| | Domain | V1 `domain(name)` single | V1 `domains(is_active: true)` full list + client-side filter to selected names | | Project | V2 `projectV2(projectId)` single | V2 `adminProjectsV2(filter: { id: { in: $projectIds } }, limit: 10)` | | KRP | V2 `adminKeypairResourcePolicyV2(name)` single | V2 `adminKeypairResourcePoliciesV2(filter: { name: { in: $names } }, limit: 10)` | The Domain table client-filters because `DomainV2` does not yet expose `allowedVfolderHosts` and the V1 `domains` field has no name filter. Backend.AI domain counts are small in practice (<20), so the cost is negligible. A `TODO(DomainV2)` comment marks the future migration to `domainsV2(filter: { name: { in: $names } })` once V2 adds the field. The fetch is still gated by `@skip` + `fetchPolicy: 'store-only'` when the selection is empty, so no network call happens at idle. ## Per-row edit state `isEditOpen: boolean` → `editingRow: T | null`. Each row's edit action sets `editingRow`; the modal opens with that row's enabled set, mutation closure, and modal title. ## i18n New key (en/ko) under `storageHost.permission.*`: - `SelectionLimitExceeded` — "You can select up to {{max}} items." / "최대 {{max}}개까지 선택할 수 있습니다." 19 target locales will be auto-translated by the i18n-translator agent in a follow-up pass. ## Verification - `bash scripts/verify.sh` — Relay/Lint/Format pass; TS shows only the pre-existing baseline failures - tsc baseline: 824 (no regression) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… 10) Resolves #7625 (FR-2991) — stacked on FR-2969 (#7587). Each of the three permission cards in the Storage Host detail drawer now accepts multiple entities. Selected entities become rows in the card's table. The previous single-entity selection is the N=1 case of the same flow — name column / host-allowed status / permission icons / per-row Edit action all preserved. ## Selection cap (10 per card) - The select is `mode="multiple"` with `maxTagCount="responsive"`. - Selecting an 11th item flips the select to `status="error"` (red border) and renders a small inline `Typography.Text type="danger"` below the select: "최대 10개까지 선택할 수 있습니다." - The panel still clamps the array it passes to the table to the first 10 entries, so even if the error is ignored, the network/render stay bounded. ## Per-table data fetching changes | Table | Before | After | |---|---|---| | Domain | V1 `domain(name)` single | V1 `domains(is_active: true)` full list + client-side filter to selected names | | Project | V2 `projectV2(projectId)` single | V2 `adminProjectsV2(filter: { id: { in: $projectIds } }, limit: 10)` | | KRP | V2 `adminKeypairResourcePolicyV2(name)` single | V2 `adminKeypairResourcePoliciesV2(filter: { name: { in: $names } }, limit: 10)` | The Domain table client-filters because `DomainV2` does not yet expose `allowedVfolderHosts` and the V1 `domains` field has no name filter. Backend.AI domain counts are small in practice (<20), so the cost is negligible. A `TODO(DomainV2)` comment marks the future migration to `domainsV2(filter: { name: { in: $names } })` once V2 adds the field. The fetch is still gated by `@skip` + `fetchPolicy: 'store-only'` when the selection is empty, so no network call happens at idle. ## Per-row edit state `isEditOpen: boolean` → `editingRow: T | null`. Each row's edit action sets `editingRow`; the modal opens with that row's enabled set, mutation closure, and modal title. ## i18n New key (en/ko) under `storageHost.permission.*`: - `SelectionLimitExceeded` — "You can select up to {{max}} items." / "최대 {{max}}개까지 선택할 수 있습니다." 19 target locales will be auto-translated by the i18n-translator agent in a follow-up pass. ## Verification - `bash scripts/verify.sh` — Relay/Lint/Format pass; TS shows only the pre-existing baseline failures - tsc baseline: 824 (no regression) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ac309c9 to
2157485
Compare
agatha197
left a comment
There was a problem hiding this comment.
Review notes — a few things worth addressing before merge. Great consolidation overall; the lazy-per-entity tables and idle-gating look solid. Comments below are inline. The one runtime check I couldn't verify statically (whether buildAllowedHostsPayloadFromV2 drops zero-permission hosts) is noted on storageHostPermission.ts separately.
2157485 to
2584aea
Compare
2584aea to
2cf8291
Compare
agatha197
left a comment
There was a problem hiding this comment.
I think it's better to leave redirect link for legacy url instead of removing.
yomybaby
left a comment
There was a problem hiding this comment.
LGTM! don't forget to resolve other reviews before merging.
2cf8291 to
9b25b51
Compare
9b25b51 to
5b5a38b
Compare
Merge activity
|
…rawer (#7587) Resolves #7588 (FR-2971) — under Epic [FR-2969](https://lablup.atlassian.net/browse/FR-2969). ## Overview Migrate the **storage host permission management** feature from `backend.ai-control-panel` (`/vfolders/?tab=permissions`) into `backend.ai-webui`'s Resources → Storage tab, and absorb the existing standalone **Storage Setting page** (`/storage-settings/:hostname`) into a tabbed detail drawer opened from the storage row's gear icon. Before this change operators had to switch between control-panel (for storage host permissions) and webui (for storage host quotas). This PR consolidates both flows under the gear icon on every storage row, mirroring the project's existing detail-drawer pattern (`AgentDetailDrawer`, `RoleDetailDrawer`). ## Phase 1 — Drawer skeleton + Capacity Setting tab - New `react/src/components/StorageHostDetailDrawer.tsx` and `StorageHostDetailDrawerContent.tsx`, mirroring `AgentDetailDrawer.tsx` / `AgentDetailDrawerContent.tsx`. Drawer header title = storage host id, `BAIFetchKeyButton` in `extra`, Suspense inside the content child. - The Capacity Setting tab embeds `<StorageHostSettingsPanel/>` (rewritten — see below) with the "quota not supported" `<Empty/>` fallback wrapped in `BAICard` for visual consistency. - Inner tabs use a bare `<Tabs>` inside `BAIFlex` (not `BAICard` with `tabList`), matching `AgentDetailDrawerContent`. - `StorageProxyList.tsx` row link now opens the drawer (`setDrawerStorageHostId(record.id)`) instead of navigating to `/storage-settings/...`. The drawer is mounted at the list level inside `BAIUnmountAfterClose`. ### Capacity Setting panel rewrite (`QuotaScopeTable`) `StorageHostSettingsPanel` was rewritten — the legacy `useQueryLoader` + `usePreloadedQuery` + `PreloadedQuotaScope` wrapper + `queryRef ? ... : ...` ternary chain is replaced with: - A new self-contained `QuotaScopeTable` component (replacing `QuotaScopeCard`). Owns its own `useLazyLoadQuery` gated by `@skip(if: $skip)` + `fetchPolicy: 'store-only'` when no scope is picked (zero network at idle). Embeds `QuotaSettingModal` inline under `BAIUnmountAfterClose`. - Panel-level `Form` removed — `userId` / `projectId` held in plain `useState` slots so switching the User/Project radio preserves each side's last pick. - antd `Table` → `BAITable`. The `quota_scope_id` column uses `BAINameActionCell` with inline Edit + Unset actions (`type: 'danger'` + Popconfirm; the Promise-returning `onConfirm` makes the Popconfirm OK-button spinner reflect mutation state). - Removes the unreachable `addQuotaConfigsWhenEmpty` branch — `quota_scope` is always assigned for any picked entity. ## Phase 2 — Permissions tab (rebuilt from the original PR design) The original design used a single panel-level query that fetched **all** domains / groups (capped at 500) / keypair resource policies and rendered three full checkbox matrices with a panel-level Update button. After review feedback that scrolling 500+ rows and bulk-editing them was the wrong UX, this PR replaces that with **per-entity lazy tables driven by selectors**: - **`StorageHostPermissionPanel`** is now a thin coordinator: one panel-level `useLazyLoadQuery` for `vfolder_host_permissions.vfolder_host_permission_list` (the canonical permission key list, used for column headers), three `useState` slots for the per-card selections, and three `BAICard`s — each card has a select in the header `extra` and a single-row table in the body. - Top of the panel renders a `BAIAlert` (type=info) explaining that a user's effective permissions are the **union** of their domain, project, and keypair resource policy entries. ### Three per-entity tables (one row each) - **`DomainStoragePermissionTable`** — V1 `domain(name: String): Domain` + `allowed_vfolder_hosts` JSONString. Domain stays on V1 because `DomainV2` does not expose `allowedVfolderHosts`. - **`ProjectStoragePermissionTable`** — V2 `projectV2(projectId: UUID!): ProjectV2` + `storage.allowedVfolderHosts: [VFolderHostPermissionEntry!]!`. No `toGlobalId` dance — the V2 query takes the raw UUID returned by `BAIAdminProjectSelect`. - **`KeypairResourcePolicyStoragePermissionTable`** — V2 `adminKeypairResourcePolicyV2(name: String!): KeypairResourcePolicyV2` + `allowedVfolderHosts`. Each table: - Issues its own `useLazyLoadQuery` gated by `@skip` + `fetchPolicy: 'store-only'` when no selection (no network call at idle). - `network-only` + bumped `fetchKey` on save success → re-fetches the entity so the table reflects the just-saved permissions immediately. The V1 `modify_*` mutations return only `{ ok, msg }`, so Relay cannot auto-update the cached entry. - `useDeferredValue` on query variables and fetchKey keeps the previous row visible (with `BAITable.loading` spinner) instead of falling through to a Suspense fallback during entity switch or post-save refetch. - Extends `BAITableProps<RowType>` so the panel can pass `locale`, `scroll`, etc. through. - Name column uses `BAINameActionCell` with an Edit action. Under the name, a small `Typography.Text type="secondary"` line shows **host-allowed status** with `CheckCircleOutlined` (green) / `CloseCircleOutlined` (red). - Permission columns are **read-only icons** — `CheckCircleOutlined` (token.colorSuccess) when enabled, `CloseCircleOutlined` (token.colorTextDisabled) otherwise. ### Shared `StoragePermissionEditModal` Edit action opens a single shared modal (`BAIUnmountAfterClose`-wrapped). Each table injects its own `onSave` callback that runs the appropriate `modify_*` mutation. - Top row: master "전체" `Checkbox` with `indeterminate` state and `n / m` count on the right. - A `Divider` and a secondary-color "권한" section label below the master, mirroring the antd Dropdown group-header style. - Permission checkboxes use `PERMISSION_DISPLAY_MAP` for labels. - **Mount-in-session warning** runs as `App.useApp().modal.confirm` (reversible save → not `BAIConfirmModalWithInput`, per `.claude/rules/destructive-confirmation.md`). Triggered when the user's edited set has `mount-in-session` without both `download-file` and `upload-file`. ### V1 ↔ V2 permission key mapping V1 stores permissions as kebab keys in a JSONString (`allowed_vfolder_hosts`); V2 returns a typed `[VFolderHostPermissionEntry!]!` with `VFolderHostPermissionV2` enum values. Most enum values lowercase-and-dash cleanly (`CREATE_VFOLDER` ↔ `create-vfolder`), but **`SET_USER_PERM` ↔ `set-user-specific-permission` is asymmetric**. Without an explicit map, the naive conversion produced `set-user-perm`, which never matched the canonical column key from `vfolder_host_permissions.vfolder_host_permission_list` — the "권한 설정" column silently appeared unchecked even when the entity had the permission, and round-tripped saves would corrupt other hosts' entries. `react/src/helper/storageHostPermission.ts` defines a spelled-out `V2_TO_V1_PERMISSION` map with a naive fallback for forward-compat. ### Mutations still on V1 Reads use V2 (structured array) where available; writes stay on the V1 mutations (`modify_domain` / `modify_group` / `modify_keypair_resource_policy`) since the corresponding V2 mutations are not wired yet. `buildAllowedHostsPayloadFromV2` rebuilds the V1 JSONString from V2 entries, preserving all other hosts' entries verbatim. ### Drop the queryLoader plumbing The eager `useQueryLoader(StorageHostPermissionsQuery)` + `loadPermissionsQuery({}, { fetchPolicy: 'store-and-network' })` on row open is gone: - `StorageProxyList.tsx` drops `useQueryLoader`, the load call on row click, and the props that forwarded the ref to the drawer. - `StorageHostDetailDrawer.tsx` and `StorageHostDetailDrawerContent.tsx` drop `permissionsQueryRef` and `onReloadPermissions` props. - Each table's own `useLazyLoadQuery` replaces the panel-level preloaded query. ### Permissions tab gating Still **superadmin only** via `useCurrentUserRole()` from `react/src/hooks/backendai.tsx`. Domain-admins and regular users see only the Capacity Setting tab. ## Phase 3 — Old standalone page removal - Deletes `react/src/pages/StorageHostSettingPage.tsx`. - `react/src/routes.tsx`: removes the lazy import; the `/storage-settings/:hostname` route now renders `<WebUINavigate to="/agent" replace />`. - `react/src/components/MainLayout/WebUISider.tsx`: drops the `storage-settings` → `agent` ternary in `selectedKeys`. - `react/src/hooks/useWebUIMenuItems.tsx`: drops the two `storage-settings` special cases. ## New `BAIAdminKeypairResourcePolicySelect` (backend.ai-ui) Modeled on `BAIAdminProjectSelect`. Uses the new V2 `adminKeypairResourcePoliciesV2(filter: KeypairResourcePolicyV2Filter, orderBy: [KeypairResourcePolicyV2OrderBy!])` (added in 26.4.2) for the paginated dropdown with name-based search (`name: { contains }`). Drop-open gated `network-only` (no fetch at mount). KRP `name` is both identifier and display label, so no separate value query. Exported from `packages/backend.ai-ui/src/components/fragments/index.ts` alongside the existing admin selects. ## Drive-by fixes (backend.ai-ui) - **`BAICard.cloneElement` style merge** — the `extra` element's `style` was being **replaced** with `{ fontWeight: 'normal' }`, silently wiping any `style.width` a consumer set on a Select used as the card extra. Now merges so consumer-set sizing wins; `fontWeight: 'normal'` is the default, and consumer-set `fontWeight` still wins (spread after). - **`BAIDomainSelect` Props** — `interface Props extends Omit<SelectProps, 'role'>` (matching `BAISelectProps`), removing the pre-existing baseline `Property 'role' is missing` TS error from this component and from the existing `UserSettingModal` call sites. ## i18n New keys under `storageHost.permission.*`: - `All`, `EditPermissions`, `EditPermissionsAction`, `EffectivePermissionsNote`, `HostAllowed`, `HostNotAllowed`, `NoDomainSelected`, `NoProjectSelected`, `NoKeypairResourcePolicySelected`, `Permissions` New key under `comp:BAIAdminKeypairResourcePolicySelect.*`: - `SelectKeypairResourcePolicy` All keys propagated to **all 19 target locales** by the `i18n-translator` agent. Korean uses `자원` (not `리소스`) per the PR-2969 terminology decision. ## Screenshots ### 1. Storage list — Resources › 스토리지 Storage list row's ID column is now the drawer trigger (replaces the old route nav to `/storage-settings/:hostname`).  ### 2. Detail drawer — Permissions tab (default for superadmin, no selection yet) Top BAIAlert explains the union rule. Three cards (도메인 / 프로젝트 / 키페어 자원 정책) — each with its select in the card extra. Tables start empty with a "선택해주세요" hint and skip the network entirely until a selection is made (`@skip` + `store-only`).  ### 3. Permissions tab — domain selected ("default") Picking a domain lazily fetches just that single entity (V1 `domain(name)` for Domain; V2 `projectV2` / `adminKeypairResourcePolicyV2` for the other two). Name column shows the host-allowed status under the name (✓ "호스트 허용됨" in this case); permission columns render as read-only ✓/✗ icons.  ### 4. Edit modal — `권한 수정 — default` Triggered by the edit icon in the row's `BAINameActionCell`. Master "전체" checkbox with indeterminate support + `n / m` count, "권한" section label below the divider, then per-permission checkboxes. Save runs the appropriate V1 `modify_*` mutation built from the V2 structured `allowedVfolderHosts` (preserving every other host's entries) and bumps the table's `fetchKey` so the new state is reflected immediately.  ### 5. Capacity Setting tab — quota not supported For storage backends without quota capability (here, the `vfs` test volume), the tab renders the "이 스토리지 백엔드는 가용량 설정을 지원하지 않습니다." `<Empty>` fallback wrapped in `BAICard` for visual consistency with the rest of the tab body.  ## Verification `bash scripts/verify.sh` results: ``` === Relay === PASS === Lint === PASS === Format === PASS === TypeScript === FAIL (pre-existing baseline) === Vite warmup paths === PASS ``` The TypeScript failures are the same pre-existing baseline (15 errors in `SessionLauncherPage.tsx`, `StatisticsPage.tsx`, `UserCredentialsPage.tsx`, `UserSettingsPage.tsx`, `VFolderNodeListPage.tsx`) listed in the original PR description. Baseline `tsc --noEmit` (react/) — original PR reported 835; current PR reports **824** (net -11), thanks to (a) the deleted `StorageHostSettingPage.tsx`, (b) the rewrite removing several wrapper components, and (c) the `BAIDomainSelect` `Omit<'role'>` drive-by clearing 3 baseline errors from sibling call sites. No new TS errors introduced. ## Acceptance criteria - [x] Clicking the gear icon on any storage row opens `StorageHostDetailDrawer` with the storage host id as the drawer title. - [x] Drawer body renders `StorageHostResourcePanel` (Endpoint / Backend / Capabilities / Usage) as a top section. - [x] Drawer body renders a `Tabs` with "Permissions" (superadmin, default) and "Capacity Setting". - [x] Capacity Setting tab renders `<StorageHostSettingsPanel/>` with the "quota not supported" `<Empty/>` (wrapped in `BAICard`) fallback preserved. - [x] Permissions tab renders three `BAICard`s (Domains / Projects / Keypair Resource Policies), each with a select in `extra` and a single-row table in the body. - [x] Each table fetches its entity lazily only after a selection (no idle network). - [x] Each table shows host-allowed status under the name and check/close icons in the permission columns. - [x] Edit action opens `StoragePermissionEditModal` with a master "전체" + indeterminate + `n/m` count, "권한" section label, and per-permission checkboxes. - [x] Save issues the correct V1 `modify_*` mutation with a JSONString payload that preserves permissions for other storage hosts (built from V2 structured entries for Project/KRP). - [x] Save success bumps the table's `fetchKey` so the new permission state is reflected without a manual refresh. - [x] Mount-in-session warning runs as `modal.confirm` (reversible action) inside the edit modal. - [x] `StorageHostSettingPage.tsx` deleted; `/storage-settings/:hostname` redirects to `/agent`. - [x] All new `.tsx` files start with `'use memo'`; no new `useMemo` / `useCallback`. - [x] New i18n keys added and propagated to all 21 locales (en/ko hand-written, 19 others auto-translated). [FR-2969]: https://lablup.atlassian.net/browse/FR-2969?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
5b5a38b to
e0cd4c2
Compare
… 10) (#7626) Resolves #7625 (FR-2991) — stacked on top of [#7587](#7587) (`fr-2969-storage-host-permission-migration`). ## Overview Adds **multi-select** to the three permission cards in the Storage Host detail drawer (introduced in [#7587](#7587)). Operators can now pick several domains / projects / keypair resource policies at once and compare their permissions side-by-side, with a per-row deselect action to quickly drop one from the comparison. ## Multi-select per card Each of `BAIDomainSelect`, `BAIAdminProjectSelect`, `BAIAdminKeypairResourcePolicySelect` is now in `mode="multiple"` (with `maxTagCount="responsive"`). Panel state changes from `string | undefined` → `string[]`. ### Per-table data fetching change | Table | Before (single) | After (multi) | |---|---|---| | Domain | V1 `domain(name: String): Domain` | V1 `domains(is_active: true)` (full list) + **client-side filter** to the selected names | | Project | V2 `projectV2(projectId: UUID!)` | V2 `adminProjectsV2(filter: { id: { in: $projectIds } }, limit: 10)` | | KRP | V2 `adminKeypairResourcePolicyV2(name: String!)` | V2 `adminKeypairResourcePoliciesV2(filter: { name: { in: $names } }, limit: 10)` | The fetch is still `@skip`-gated + `fetchPolicy: 'store-only'` when the selection is empty, so the network stays idle until at least one item is picked. **Why Domain stays on V1**: `DomainV2` does not expose `allowedVfolderHosts` (only `ProjectV2.storage.*` and `KeypairResourcePolicyV2.*` do), and the V1 `domains` field has no name filter. Backend.AI domain counts are small in practice (<20), so fetching the full list and filtering client-side is cheap. A `TODO(DomainV2)` comment in the file flags the future migration to `domainsV2(filter: { name: { in: $names } })` once V2 adds the field. ## Selection cap (10 per card) - Selecting an 11th item flips the select to `status="error"` (red border) and renders an inline `Typography.Text type="danger"` below it: "최대 10개까지 선택할 수 있습니다." / "You can select up to 10 items." - The panel still clamps the array it passes to the table to the first 10 entries, so even if the user dismisses the error, the network and rendered DOM stay bounded. - Removing any selection brings the count back to 10 or fewer and clears the error. ## Per-row Deselect action `BAINameActionCell` now has a second action — a gray `MinusCircleOutlined` icon next to the Edit pencil — that removes that single row from the parent select's selection. - New `onDeselectItem?: (key: string) => void` prop on each *Table; the key is the name for Domain / KRP and the raw UUID for Project. - Panel wires the callback to `setSelectedXxx(prev => prev.filter(...))`. - Icon color uses `token.colorTextTertiary` (inline) so the gray reads as a secondary action — the primary blue Edit action stays visually dominant. The icon's `actionButtonDefault` hover keeps its default blue background (we kept BAINameActionCell's existing `default | danger` type system unchanged rather than introducing a new variant). ## Fix: multi-select chips silently collapsed to the first item `BAIAdminProjectSelect`'s value query took a single `projectId: UUID!`, so in `mode="multiple"` only the first selected id had its name resolved — `controllableValueWithLabel` returned `[singleEntry]`, every other selected id silently vanished from the controlled-value display, and the downstream `selectedProjectUuids` in the panel ended up capped at the 1 visible chip. The Project table then only fetched and rendered the 1. Refactored the value query to `adminProjectsV2(filter: { id: { in: $projectIds } }, limit: 100)`: - Resolves names for every selected id in one round-trip. - Builds a `selectedNameByLocalId` map and maps every entry in `deferredControllableValue` through it. - Falls back to the raw UUID as label until the name lookup settles — every chip appears immediately, the name swaps in when the network responds. - Backward-compatible with single-mode consumers (1-element array). `BAIDomainSelect` and `BAIAdminKeypairResourcePolicySelect` were not affected — Domain loads every option upfront so antd Select's built-in option matching handles multi natively, and KRP uses `name` as both value and label so no separate label-resolution query exists in the first place. ## i18n New keys under `storageHost.permission.*`: - `SelectionLimitExceeded` — "You can select up to {{max}} items." / "최대 {{max}}개까지 선택할 수 있습니다." - `DeselectItem` — "Deselect" / "선택 해제" Both keys propagated to all 19 target locales by the `i18n-translator` agent. ## Screenshots ### 1. Multi-select + per-row deselect action Domain card with the multi-select chip ("default ×") and a single row in the table. The row's `BAINameActionCell` shows two actions side-by-side — the blue Edit pencil (primary) and the gray `MinusCircleOutlined` deselect (secondary, `token.colorTextTertiary`). The test backend only has one domain; the action visibility is the demonstration here.  ### 2. Close-up of the row action (Edit + Deselect) Zoomed view of the Domain card showing the two actions next to the name. Edit (blue, primary) and Deselect (gray, secondary) — the contrast keeps Edit visually dominant.  ### 3. Project card — 4-row comparison view Multi-select with 4 selected projects (chips: `default ×`, `model-store ×`, `+ 2 ...` via `maxTagCount="responsive"`). The table renders one row per project, each independently fetched via the new V2 list-with-filter query `adminProjectsV2(filter: { id: { in: $projectIds } }, limit: 10)`. Note the `test` row at the bottom: it shows ✓ "호스트 허용됨" (the host entry exists for this project) but every permission column is ✗ — exactly the "host allowed but no permissions granted" distinction the V1 JSON's empty-array case represents. Each row has its own Edit + Deselect actions.  ### Selection cap (11 items) — not pictured The current test backend (`10.122.10.107:8090`) only exposes a few domains / projects / keypair resource policies (the largest count being 4 projects), so the "select 11 to trigger the red error state" scenario is not reachable here. The behaviour is implemented (see `StorageHostPermissionPanel.tsx` — the select gets `status="error"` and an inline `Typography.Text type="danger"` below it; the panel still clamps the array to the first 10 entries before passing it to the table). Verify against any environment with 11+ entries. ## Verification `bash scripts/verify.sh` results: ``` === Relay === PASS === Lint === PASS === Format === PASS === TypeScript === FAIL (pre-existing baseline) === Vite warmup paths === PASS ``` TypeScript failures are the same pre-existing baseline from `#7587` (`SessionLauncherPage.tsx`, `StatisticsPage.tsx`, `UserCredentialsPage.tsx`, `UserSettingsPage.tsx`, `VFolderNodeListPage.tsx`). `tsc --noEmit` baseline: react 824 / backend.ai-ui 275 — both unchanged by this PR. No new TS errors. ## Acceptance criteria - [x] All three selects accept multiple selections. - [x] Each table renders one row per selected entity, with the existing per-row name / host-allowed status / permission icons / Edit action preserved. - [x] Project and KRP tables switch to `*V2(filter)` list queries; Domain table fetches all `domains` and filters client-side, with a `TODO(DomainV2)` comment. - [x] Selecting an 11th item flips the select to error state with a clear localized message; removing a selection clears the error. - [x] Edit save flow still refetches the corresponding table via `fetchKey` bump (same pattern as `#7587`). - [x] Per-row deselect action with `MinusCircleOutlined` (gray via `token.colorTextTertiary`) removes the row from the parent select's selection. - [x] `BAIAdminProjectSelect` shows all selected chips in multi-mode (was capped at 1 before this PR). - [x] `bash scripts/verify.sh` passes (or shows only pre-existing baseline failures). - [x] New i18n keys propagated to all 21 locales (en/ko hand-written; 19 others auto-translated by the i18n-translator agent).

Resolves #7588 (FR-2971) — under Epic FR-2969.
Overview
Migrate the storage host permission management feature from
backend.ai-control-panel(/vfolders/?tab=permissions) intobackend.ai-webui's Resources → Storage tab, and absorb the existing standalone Storage Setting page (/storage-settings/:hostname) into a tabbed detail drawer opened from the storage row's gear icon.Before this change operators had to switch between control-panel (for storage host permissions) and webui (for storage host quotas). This PR consolidates both flows under the gear icon on every storage row, mirroring the project's existing detail-drawer pattern (
AgentDetailDrawer,RoleDetailDrawer).Phase 1 — Drawer skeleton + Capacity Setting tab
react/src/components/StorageHostDetailDrawer.tsxandStorageHostDetailDrawerContent.tsx, mirroringAgentDetailDrawer.tsx/AgentDetailDrawerContent.tsx. Drawer header title = storage host id,BAIFetchKeyButtoninextra, Suspense inside the content child.<StorageHostSettingsPanel/>(rewritten — see below) with the "quota not supported"<Empty/>fallback wrapped inBAICardfor visual consistency.<Tabs>insideBAIFlex(notBAICardwithtabList), matchingAgentDetailDrawerContent.StorageProxyList.tsxrow link now opens the drawer (setDrawerStorageHostId(record.id)) instead of navigating to/storage-settings/.... The drawer is mounted at the list level insideBAIUnmountAfterClose.Capacity Setting panel rewrite (
QuotaScopeTable)StorageHostSettingsPanelwas rewritten — the legacyuseQueryLoader+usePreloadedQuery+PreloadedQuotaScopewrapper +queryRef ? ... : ...ternary chain is replaced with:QuotaScopeTablecomponent (replacingQuotaScopeCard). Owns its ownuseLazyLoadQuerygated by@skip(if: $skip)+fetchPolicy: 'store-only'when no scope is picked (zero network at idle). EmbedsQuotaSettingModalinline underBAIUnmountAfterClose.Formremoved —userId/projectIdheld in plainuseStateslots so switching the User/Project radio preserves each side's last pick.Table→BAITable. Thequota_scope_idcolumn usesBAINameActionCellwith inline Edit + Unset actions (type: 'danger'+ Popconfirm; the Promise-returningonConfirmmakes the Popconfirm OK-button spinner reflect mutation state).addQuotaConfigsWhenEmptybranch —quota_scopeis always assigned for any picked entity.Phase 2 — Permissions tab (rebuilt from the original PR design)
The original design used a single panel-level query that fetched all domains / groups (capped at 500) / keypair resource policies and rendered three full checkbox matrices with a panel-level Update button. After review feedback that scrolling 500+ rows and bulk-editing them was the wrong UX, this PR replaces that with per-entity lazy tables driven by selectors:
StorageHostPermissionPanelis now a thin coordinator: one panel-leveluseLazyLoadQueryforvfolder_host_permissions.vfolder_host_permission_list(the canonical permission key list, used for column headers), threeuseStateslots for the per-card selections, and threeBAICards — each card has a select in the headerextraand a single-row table in the body.BAIAlert(type=info) explaining that a user's effective permissions are the union of their domain, project, and keypair resource policy entries.Three per-entity tables (one row each)
DomainStoragePermissionTable— V1domain(name: String): Domain+allowed_vfolder_hostsJSONString. Domain stays on V1 becauseDomainV2does not exposeallowedVfolderHosts.ProjectStoragePermissionTable— V2projectV2(projectId: UUID!): ProjectV2+storage.allowedVfolderHosts: [VFolderHostPermissionEntry!]!. NotoGlobalIddance — the V2 query takes the raw UUID returned byBAIAdminProjectSelect.KeypairResourcePolicyStoragePermissionTable— V2adminKeypairResourcePolicyV2(name: String!): KeypairResourcePolicyV2+allowedVfolderHosts.Each table:
useLazyLoadQuerygated by@skip+fetchPolicy: 'store-only'when no selection (no network call at idle).network-only+ bumpedfetchKeyon save success → re-fetches the entity so the table reflects the just-saved permissions immediately. The V1modify_*mutations return only{ ok, msg }, so Relay cannot auto-update the cached entry.useDeferredValueon query variables and fetchKey keeps the previous row visible (withBAITable.loadingspinner) instead of falling through to a Suspense fallback during entity switch or post-save refetch.BAITableProps<RowType>so the panel can passlocale,scroll, etc. through.BAINameActionCellwith an Edit action. Under the name, a smallTypography.Text type="secondary"line shows host-allowed status withCheckCircleOutlined(green) /CloseCircleOutlined(red).CheckCircleOutlined(token.colorSuccess) when enabled,CloseCircleOutlined(token.colorTextDisabled) otherwise.Shared
StoragePermissionEditModalEdit action opens a single shared modal (
BAIUnmountAfterClose-wrapped). Each table injects its ownonSavecallback that runs the appropriatemodify_*mutation.Checkboxwithindeterminatestate andn / mcount on the right.Dividerand a secondary-color "권한" section label below the master, mirroring the antd Dropdown group-header style.PERMISSION_DISPLAY_MAPfor labels.App.useApp().modal.confirm(reversible save → notBAIConfirmModalWithInput, per.claude/rules/destructive-confirmation.md). Triggered when the user's edited set hasmount-in-sessionwithout bothdownload-fileandupload-file.V1 ↔ V2 permission key mapping
V1 stores permissions as kebab keys in a JSONString (
allowed_vfolder_hosts); V2 returns a typed[VFolderHostPermissionEntry!]!withVFolderHostPermissionV2enum values. Most enum values lowercase-and-dash cleanly (CREATE_VFOLDER↔create-vfolder), butSET_USER_PERM↔set-user-specific-permissionis asymmetric. Without an explicit map, the naive conversion producedset-user-perm, which never matched the canonical column key fromvfolder_host_permissions.vfolder_host_permission_list— the "권한 설정" column silently appeared unchecked even when the entity had the permission, and round-tripped saves would corrupt other hosts' entries.react/src/helper/storageHostPermission.tsdefines a spelled-outV2_TO_V1_PERMISSIONmap with a naive fallback for forward-compat.Mutations still on V1
Reads use V2 (structured array) where available; writes stay on the V1 mutations (
modify_domain/modify_group/modify_keypair_resource_policy) since the corresponding V2 mutations are not wired yet.buildAllowedHostsPayloadFromV2rebuilds the V1 JSONString from V2 entries, preserving all other hosts' entries verbatim.Drop the queryLoader plumbing
The eager
useQueryLoader(StorageHostPermissionsQuery)+loadPermissionsQuery({}, { fetchPolicy: 'store-and-network' })on row open is gone:StorageProxyList.tsxdropsuseQueryLoader, the load call on row click, and the props that forwarded the ref to the drawer.StorageHostDetailDrawer.tsxandStorageHostDetailDrawerContent.tsxdroppermissionsQueryRefandonReloadPermissionsprops.useLazyLoadQueryreplaces the panel-level preloaded query.Permissions tab gating
Still superadmin only via
useCurrentUserRole()fromreact/src/hooks/backendai.tsx. Domain-admins and regular users see only the Capacity Setting tab.Phase 3 — Old standalone page removal
react/src/pages/StorageHostSettingPage.tsx.react/src/routes.tsx: removes the lazy import; the/storage-settings/:hostnameroute now renders<WebUINavigate to="/agent" replace />.react/src/components/MainLayout/WebUISider.tsx: drops thestorage-settings→agentternary inselectedKeys.react/src/hooks/useWebUIMenuItems.tsx: drops the twostorage-settingsspecial cases.New
BAIAdminKeypairResourcePolicySelect(backend.ai-ui)Modeled on
BAIAdminProjectSelect. Uses the new V2adminKeypairResourcePoliciesV2(filter: KeypairResourcePolicyV2Filter, orderBy: [KeypairResourcePolicyV2OrderBy!])(added in 26.4.2) for the paginated dropdown with name-based search (name: { contains }). Drop-open gatednetwork-only(no fetch at mount). KRPnameis both identifier and display label, so no separate value query.Exported from
packages/backend.ai-ui/src/components/fragments/index.tsalongside the existing admin selects.Drive-by fixes (backend.ai-ui)
BAICard.cloneElementstyle merge — theextraelement'sstylewas being replaced with{ fontWeight: 'normal' }, silently wiping anystyle.widtha consumer set on a Select used as the card extra. Now merges so consumer-set sizing wins;fontWeight: 'normal'is the default, and consumer-setfontWeightstill wins (spread after).BAIDomainSelectProps —interface Props extends Omit<SelectProps, 'role'>(matchingBAISelectProps), removing the pre-existing baselineProperty 'role' is missingTS error from this component and from the existingUserSettingModalcall sites.i18n
New keys under
storageHost.permission.*:All,EditPermissions,EditPermissionsAction,EffectivePermissionsNote,HostAllowed,HostNotAllowed,NoDomainSelected,NoProjectSelected,NoKeypairResourcePolicySelected,PermissionsNew key under
comp:BAIAdminKeypairResourcePolicySelect.*:SelectKeypairResourcePolicyAll keys propagated to all 19 target locales by the
i18n-translatoragent. Korean uses자원(not리소스) per the PR-2969 terminology decision.Screenshots
1. Storage list — Resources › 스토리지
Storage list row's ID column is now the drawer trigger (replaces the old route nav to
/storage-settings/:hostname).2. Detail drawer — Permissions tab (default for superadmin, no selection yet)
Top BAIAlert explains the union rule. Three cards (도메인 / 프로젝트 / 키페어 자원 정책) — each with its select in the card extra. Tables start empty with a "선택해주세요" hint and skip the network entirely until a selection is made (
@skip+store-only).3. Permissions tab — domain selected ("default")
Picking a domain lazily fetches just that single entity (V1
domain(name)for Domain; V2projectV2/adminKeypairResourcePolicyV2for the other two). Name column shows the host-allowed status under the name (✓ "호스트 허용됨" in this case); permission columns render as read-only ✓/✗ icons.4. Edit modal —
권한 수정 — defaultTriggered by the edit icon in the row's
BAINameActionCell. Master "전체" checkbox with indeterminate support +n / mcount, "권한" section label below the divider, then per-permission checkboxes. Save runs the appropriate V1modify_*mutation built from the V2 structuredallowedVfolderHosts(preserving every other host's entries) and bumps the table'sfetchKeyso the new state is reflected immediately.5. Capacity Setting tab — quota not supported
For storage backends without quota capability (here, the
vfstest volume), the tab renders the "이 스토리지 백엔드는 가용량 설정을 지원하지 않습니다."<Empty>fallback wrapped inBAICardfor visual consistency with the rest of the tab body.Verification
bash scripts/verify.shresults:The TypeScript failures are the same pre-existing baseline (15 errors in
SessionLauncherPage.tsx,StatisticsPage.tsx,UserCredentialsPage.tsx,UserSettingsPage.tsx,VFolderNodeListPage.tsx) listed in the original PR description. Baselinetsc --noEmit(react/) — original PR reported 835; current PR reports 824 (net -11), thanks to (a) the deletedStorageHostSettingPage.tsx, (b) the rewrite removing several wrapper components, and (c) theBAIDomainSelectOmit<'role'>drive-by clearing 3 baseline errors from sibling call sites. No new TS errors introduced.Acceptance criteria
StorageHostDetailDrawerwith the storage host id as the drawer title.StorageHostResourcePanel(Endpoint / Backend / Capabilities / Usage) as a top section.Tabswith "Permissions" (superadmin, default) and "Capacity Setting".<StorageHostSettingsPanel/>with the "quota not supported"<Empty/>(wrapped inBAICard) fallback preserved.BAICards (Domains / Projects / Keypair Resource Policies), each with a select inextraand a single-row table in the body.StoragePermissionEditModalwith a master "전체" + indeterminate +n/mcount, "권한" section label, and per-permission checkboxes.modify_*mutation with a JSONString payload that preserves permissions for other storage hosts (built from V2 structured entries for Project/KRP).fetchKeyso the new permission state is reflected without a manual refresh.modal.confirm(reversible action) inside the edit modal.StorageHostSettingPage.tsxdeleted;/storage-settings/:hostnameredirects to/agent..tsxfiles start with'use memo'; no newuseMemo/useCallback.