Skip to content

feat(FR-2969): migrate storage host permission management to detail drawer#7587

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fr-2969-storage-host-permission-migration
Jun 4, 2026
Merged

feat(FR-2969): migrate storage host permission management to detail drawer#7587
graphite-app[bot] merged 1 commit into
mainfrom
fr-2969-storage-host-permission-migration

Conversation

@ironAiken2

@ironAiken2 ironAiken2 commented May 26, 2026

Copy link
Copy Markdown
Contributor

Resolves #7588 (FR-2971) — under Epic 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 TableBAITable. 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 BAICards — 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 iconsCheckCircleOutlined (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_VFOLDERcreate-vfolder), but SET_USER_PERMset-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-settingsagent 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 Propsinterface 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).

storage-list

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).

detail-drawer-permissions

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.

permissions-domain-selected

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.

permissions-edit-modal

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.

capacity-tab-not-supported

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

  • Clicking the gear icon on any storage row opens StorageHostDetailDrawer with the storage host id as the drawer title.
  • Drawer body renders StorageHostResourcePanel (Endpoint / Backend / Capabilities / Usage) as a top section.
  • Drawer body renders a Tabs with "Permissions" (superadmin, default) and "Capacity Setting".
  • Capacity Setting tab renders <StorageHostSettingsPanel/> with the "quota not supported" <Empty/> (wrapped in BAICard) fallback preserved.
  • Permissions tab renders three BAICards (Domains / Projects / Keypair Resource Policies), each with a select in extra and a single-row table in the body.
  • Each table fetches its entity lazily only after a selection (no idle network).
  • Each table shows host-allowed status under the name and check/close icons in the permission columns.
  • Edit action opens StoragePermissionEditModal with a master "전체" + indeterminate + n/m count, "권한" section label, and per-permission checkboxes.
  • 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).
  • Save success bumps the table's fetchKey so the new permission state is reflected without a manual refresh.
  • Mount-in-session warning runs as modal.confirm (reversible action) inside the edit modal.
  • StorageHostSettingPage.tsx deleted; /storage-settings/:hostname redirects to /agent.
  • All new .tsx files start with 'use memo'; no new useMemo / useCallback.
  • New i18n keys added and propagated to all 21 locales (en/ko hand-written, 19 others auto-translated).

@github-actions github-actions Bot added area:ux UI / UX issue. area:i18n Localization size:XL 500~ LoC labels May 26, 2026
@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for react-coverage (./react)

Status Category Percentage Covered / Total
🔵 Lines 6.56% 1808 / 27555
🔵 Statements 5.33% 2004 / 37549
🔵 Functions 5.38% 300 / 5567
🔵 Branches 3.72% 1306 / 35073
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
react/src/routes.tsx 0% 0% 0% 0% 41-1021
react/src/components/DomainStoragePermissionTable.tsx 0% 0% 0% 0% 45-141
react/src/components/KeypairResourcePolicyStoragePermissionTable.tsx 0% 0% 0% 0% 48-155
react/src/components/ProjectStoragePermissionTable.tsx 0% 0% 0% 0% 47-149
react/src/components/QuotaScopeTable.tsx 0% 0% 0% 0% 30-184
react/src/components/StorageHostDetailDrawer.tsx 0% 0% 0% 0% 26-54
react/src/components/StorageHostDetailDrawerContent.tsx 0% 0% 0% 0% 26-54
react/src/components/StorageHostPermissionPanel.tsx 0% 0% 0% 0% 26-61
react/src/components/StorageHostResourcePanel.tsx 0% 0% 0% 0% 15-76
react/src/components/StorageHostSettingsPanel.tsx 0% 0% 0% 0% 19-52
react/src/components/StoragePermissionEditModal.tsx 0% 0% 0% 0% 31-133
react/src/components/StorageProxyList.tsx 0% 0% 0% 0% 34-308
react/src/components/MainLayout/WebUISider.tsx 0% 0% 0% 0% 44-382
react/src/helper/storageHostPermission.ts 0% 0% 0% 0% 19-161
react/src/hooks/useWebUIMenuItems.tsx 0% 0% 0% 0% 64-818
Generated in workflow #1375 for commit e0cd4c2 by the Vitest Coverage Report Action

@ironAiken2 ironAiken2 marked this pull request as ready for review May 26, 2026 04:24
Copilot AI review requested due to automatic review settings May 26, 2026 04:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via BAIFetchKeyButton).
  • Adds a superadmin-only “Permissions” tab (StorageHostPermissionPanel) that edits allowed_vfolder_hosts for domains/groups/keypair resource policies via Relay mutations.
  • Removes the standalone settings page and redirects /storage-settings/:hostname to /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

Comment thread react/src/components/StorageHostDetailDrawerContent.tsx Outdated
Comment thread react/src/components/StorageHostPermissionPanel.tsx Outdated
@ironAiken2

Copy link
Copy Markdown
Contributor Author

Verify Spec Results

Spec: .specs/FR-2969-storage-host-permission-migration/spec.md

Category PASS PARTIAL MISS
UI behavior 13 0 0
Migration / removal 4 0 0
Role gating 2 0 0
i18n 3 0 0
Code quality 4 1 0
Total 26 1 0

Spec compliance: 26/27 (96%)

The 1 PARTIAL

bash scripts/verify.sh ends with === ALL PASS ===

Relay / Lint / Format / Vite all PASS. TypeScript FAILs on 15 pre-existing baseline errors in unrelated files (SessionLauncherPage.tsx, StatisticsPage.tsx, UserCredentialsPage.tsx, UserSettingsPage.tsx, VFolderNodeListPage.tsx). This PR net-removes 3 baseline errors (the deleted StorageHostSettingPage.tsx contributed 3 of them) and introduces zero new TS errors.

Security

CRITICAL 0 / HIGH 0 / MEDIUM 0 / LOW 2 (already noted by pr-reviewer in batch-review). JSON merge preservation, defensive JSON.parse, superadmin gating, and mount-in-session warning trigger logic all verified against the spec's stated security concerns.

Type B additions (impl beyond spec)

These came out of agent reviews and are improvements over the spec's literal requirements:

  • useEffectEvent-based baseline + editable state separation, so dirty diffs survive a mid-edit refetch (store-and-network).
  • aria-label="{row name} — {permission label}" on every checkbox in the permission matrix (Copilot a11y feedback).
  • NoChanges info toast when the user clicks Update with no dirty rows.
  • Per-row failure logging via useBAILogger so support has the full picture when multiple mutations partially fail.

No spec updates needed — these are implementation details below the spec's abstraction level.

Follow-up issues

None. Every Must-Have acceptance criterion is addressed.

Verdict

PASS WITH NOTES — ready for human review and merge.


🤖 Generated by /fw:pipeline verify-spec stage

ironAiken2 commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • flow:merge-queue - adds this PR to the back of the merge queue
  • flow:hotfix - for urgent changes, fast-track this PR to the front of 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.

@github-actions

github-actions Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for backend-ai-ui-coverage (./packages/backend.ai-ui)

Status Category Percentage Covered / Total
🔵 Lines 10.77% 528 / 4901
🔵 Statements 9.21% 589 / 6393
🔵 Functions 12.36% 135 / 1092
🔵 Branches 7.74% 486 / 6277
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/backend.ai-ui/src/components/BAICard.tsx 100% 100% 100% 100%
packages/backend.ai-ui/src/components/fragments/BAIAdminKeypairResourcePolicySelect.tsx 0% 0% 0% 0% 40-191
packages/backend.ai-ui/src/components/fragments/BAIDomainSelect.tsx 14.28% 0% 0% 14.28% 16-43
packages/backend.ai-ui/src/components/fragments/index.ts 100% 100% 100% 100%
Generated in workflow #1375 for commit e0cd4c2 by the Vitest Coverage Report Action

ironAiken2 added a commit that referenced this pull request May 28, 2026
ironAiken2 added a commit that referenced this pull request May 28, 2026
… 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>
ironAiken2 added a commit that referenced this pull request May 28, 2026
… 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>
Comment thread react/src/components/MainLayout/WebUISider.tsx

@agatha197 agatha197 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread react/src/components/StorageHostSettingsPanel.tsx Outdated
Comment thread react/src/components/StorageHostDetailDrawerContent.tsx Outdated
Comment thread react/src/components/QuotaScopeTable.tsx Outdated
Comment thread react/src/components/StoragePermissionEditModal.tsx
Comment thread react/src/helper/storageHostPermission.ts

@agatha197 agatha197 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to leave redirect link for legacy url instead of removing.

Comment thread react/src/routes.tsx Outdated

@yomybaby yomybaby left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! don't forget to resolve other reviews before merging.

@ironAiken2 ironAiken2 force-pushed the fr-2969-storage-host-permission-migration branch from 2cf8291 to 9b25b51 Compare June 4, 2026 02:13
@ironAiken2 ironAiken2 requested a review from agatha197 June 4, 2026 02:14
@ironAiken2 ironAiken2 force-pushed the fr-2969-storage-host-permission-migration branch from 9b25b51 to 5b5a38b Compare June 4, 2026 02:40

@agatha197 agatha197 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@graphite-app

graphite-app Bot commented Jun 4, 2026

Copy link
Copy Markdown

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`).

![storage-list](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7587/20260528-043402-01-storage-list.png)

### 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`).

![detail-drawer-permissions](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7587/20260528-043402-02-detail-drawer-permissions.png)

### 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.

![permissions-domain-selected](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7587/20260528-043402-03-permissions-domain-selected.png)

### 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.

![permissions-edit-modal](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7587/20260528-043402-04-permissions-edit-modal.png)

### 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.

![capacity-tab-not-supported](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7587/20260528-043402-05-capacity-tab.png)

## 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
@graphite-app graphite-app Bot force-pushed the fr-2969-storage-host-permission-migration branch from 5b5a38b to e0cd4c2 Compare June 4, 2026 04:45
graphite-app Bot pushed a commit that referenced this pull request Jun 4, 2026
… 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.

![multi-domain-rows](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7626/20260528-055110-01-multi-domain-rows.png)

### 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.

![row-actions-zoom](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7626/20260528-055110-02-row-actions-zoom.png)

### 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.

![project-multi](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-7626/20260528-055110-02b-project-multi.png)

### 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).
@graphite-app graphite-app Bot merged commit e0cd4c2 into main Jun 4, 2026
13 checks passed
@graphite-app graphite-app Bot deleted the fr-2969-storage-host-permission-migration branch June 4, 2026 04:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:i18n Localization area:ux UI / UX issue. size:XL 500~ LoC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement storage host permission migration (drawer + permissions tab + cleanup)

4 participants