From ea04baca21aca8ca6fe05d1536fbc9619f07042b Mon Sep 17 00:00:00 2001 From: Vivek Rohtasvi Date: Tue, 1 Sep 2026 11:10:50 +0530 Subject: [PATCH 1/3] refactor(rse): extract protocol column definitions for #647 Issue: #647 --- .../RSE/details/DetailsRSEProtocolsTable.tsx | 263 +++++++++--------- 1 file changed, 133 insertions(+), 130 deletions(-) diff --git a/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx index 698d6b1a4..9381e0fbb 100644 --- a/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx +++ b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx @@ -2,150 +2,153 @@ import React, { useRef, useState } from 'react'; import { AgGridReact } from 'ag-grid-react'; import { RegularTable } from '@/component-library/features/table/RegularTable/RegularTable'; import { RSEDetailsProtocol } from '@/lib/core/entity/rucio'; -import { ValueGetterParams } from 'ag-grid-community'; +import { ColDef, ColGroupDef, ValueGetterParams } from 'ag-grid-community'; import { DefaultTextFilterParams } from '@/component-library/features/utils/filter-parameters'; type DetailsRSEProtocolsTableProps = { rowData: RSEDetailsProtocol[]; }; -export const DetailsRSEProtocolsTable = (props: DetailsRSEProtocolsTableProps) => { - const tableRef = useRef>(null); - - const [columnDefs] = useState([ - { - headerName: 'Protocol', - children: [ - { - headerName: 'Scheme', - field: 'scheme', - width: 120, - flex: 0, - pinned: 'left', - filter: true, - filterParams: DefaultTextFilterParams, - }, - { - headerName: 'Hostname', - field: 'hostname', - width: 250, - flex: 0, - filter: true, - filterParams: DefaultTextFilterParams, - }, - { - headerName: 'Port', - field: 'port', - width: 100, - flex: 0, - filter: 'agNumberColumnFilter', - }, - { - headerName: 'Prefix', - field: 'prefix', - width: 200, - flex: 0, - filter: true, - filterParams: DefaultTextFilterParams, +/** Exported so the column sizing can be asserted without rendering the grid. */ +export const getProtocolColumnDefs = (): (ColDef | ColGroupDef)[] => [ + { + headerName: 'Protocol', + children: [ + { + headerName: 'Scheme', + field: 'scheme', + width: 120, + flex: 0, + pinned: 'left', + filter: true, + filterParams: DefaultTextFilterParams, + }, + { + headerName: 'Hostname', + field: 'hostname', + width: 250, + flex: 0, + filter: true, + filterParams: DefaultTextFilterParams, + }, + { + headerName: 'Port', + field: 'port', + width: 100, + flex: 0, + filter: 'agNumberColumnFilter', + }, + { + headerName: 'Prefix', + field: 'prefix', + width: 200, + flex: 0, + filter: true, + filterParams: DefaultTextFilterParams, + }, + ], + }, + { + headerName: 'LAN Priorities', + children: [ + { + headerName: 'Read', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.lan.read; }, - ], - }, - { - headerName: 'LAN Priorities', - children: [ - { - headerName: 'Read', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.lan.read; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + { + headerName: 'Write', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.lan.write; }, - { - headerName: 'Write', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.lan.write; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + { + headerName: 'Delete', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.lan.delete; }, - { - headerName: 'Delete', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.lan.delete; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + ], + }, + { + headerName: 'WAN Priorities', + children: [ + { + headerName: 'Read', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.wan.read; }, - ], - }, - { - headerName: 'WAN Priorities', - children: [ - { - headerName: 'Read', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.wan.read; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + { + headerName: 'Write', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.wan.write; }, - { - headerName: 'Write', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.wan.write; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + { + headerName: 'Delete', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.wan.delete; }, - { - headerName: 'Delete', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.wan.delete; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + ], + }, + { + headerName: 'Third Party Copy', + children: [ + { + headerName: 'Read', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.wan.third_party_copy_read; }, - ], - }, - { - headerName: 'Third Party Copy', - children: [ - { - headerName: 'Read', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.wan.third_party_copy_read; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + { + headerName: 'Write', + valueGetter: (params: ValueGetterParams) => { + return params.data?.domains.wan.third_party_copy_write; }, - { - headerName: 'Write', - valueGetter: (params: ValueGetterParams) => { - return params.data?.domains.wan.third_party_copy_write; - }, - width: 90, - flex: 0, - sortable: true, - filter: 'agNumberColumnFilter', - }, - ], - }, - ]); + width: 90, + flex: 0, + sortable: true, + filter: 'agNumberColumnFilter', + }, + ], + }, +]; + +export const DetailsRSEProtocolsTable = (props: DetailsRSEProtocolsTableProps) => { + const tableRef = useRef>(null); + + const [columnDefs] = useState(getProtocolColumnDefs); return ; }; From 8ca9a57efdb234212adf01bb6490743a7daea07f Mon Sep 17 00:00:00 2001 From: Vivek Rohtasvi Date: Tue, 1 Sep 2026 11:10:50 +0530 Subject: [PATCH 2/3] fix(rse): wrap long protocol prefixes instead of truncating them for #647 Issue: #647 --- .../pages/RSE/details/DetailsRSEProtocolsTable.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx index 9381e0fbb..a5f9eeb1c 100644 --- a/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx +++ b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx @@ -41,8 +41,12 @@ export const getProtocolColumnDefs = (): (ColDef | ColGroupD { headerName: 'Prefix', field: 'prefix', - width: 200, - flex: 0, + minWidth: 300, + flex: 1, + // Deployed prefixes are deep storage paths: wrap rather than widen the table, so the + // full value stays visible without forcing horizontal scrolling on narrow viewports. + wrapText: true, + autoHeight: true, filter: true, filterParams: DefaultTextFilterParams, }, From d5715415df68cd1a13b4ac23fc676c9f89df7db4 Mon Sep 17 00:00:00 2001 From: Vivek Rohtasvi Date: Tue, 1 Sep 2026 11:10:50 +0530 Subject: [PATCH 3/3] test(rse): cover protocol prefix rendering for #647 Closes: #647 --- .../DetailsRSEProtocolsTable.stories.tsx | 48 +++++++++++++++++++ .../DetailsRSEProtocolsTable.test.ts | 33 +++++++++++++ test/fixtures/table-fixtures.ts | 33 +++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.stories.tsx create mode 100644 test/component/DetailsRSEProtocolsTable.test.ts diff --git a/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.stories.tsx b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.stories.tsx new file mode 100644 index 000000000..24d4cb404 --- /dev/null +++ b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { DetailsRSEProtocolsTable } from './DetailsRSEProtocolsTable'; +import { fixtureRSEDetailsProtocol } from '@/test/fixtures/table-fixtures'; +import { RSEDetailsProtocol } from '@/lib/core/entity/rucio'; + +const meta = { + title: 'Components/Pages/RSE/Details/ProtocolsTable', + component: DetailsRSEProtocolsTable, + parameters: { + docs: { disable: true }, + }, + // Mirrors the fixed-height container the table sits in on the RSE details page + decorators: [ + Story => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const withPrefix = (prefix: string): RSEDetailsProtocol => ({ ...fixtureRSEDetailsProtocol(), prefix }); + +export const Default: Story = { + args: { + rowData: Array.from({ length: 12 }, fixtureRSEDetailsProtocol), + }, +}; + +/** + * The prefixes seen on deployed RSEs are deep storage paths. They should be readable + * without resizing the column or hovering over the cell. + */ +export const LongPrefixes: Story = { + args: { + rowData: [ + withPrefix('/pnfs/fnal.gov/usr/cms/WAX/11/store/'), + withPrefix('/dpm/in2p3.fr/home/cms/phedex/store/mc/RunIISummer20UL18/'), + withPrefix('/eos/atlas/atlasdatadisk/rucio/'), + withPrefix('/pnfs/rucio/disk-only/scratchdisk/'), + withPrefix('/rucio/tmpdisk/rucio_tests/'), + withPrefix('/tmp/rucio_rse/'), + ], + }, +}; diff --git a/test/component/DetailsRSEProtocolsTable.test.ts b/test/component/DetailsRSEProtocolsTable.test.ts new file mode 100644 index 000000000..9f7c5f2fd --- /dev/null +++ b/test/component/DetailsRSEProtocolsTable.test.ts @@ -0,0 +1,33 @@ +import { getProtocolColumnDefs } from '@/component-library/pages/RSE/details/DetailsRSEProtocolsTable'; +import { RSEDetailsProtocol } from '@/lib/core/entity/rucio'; +import { ColDef, ColGroupDef } from 'ag-grid-community'; + +type ProtocolColDef = ColDef; +type ProtocolColumn = ProtocolColDef | ColGroupDef; + +const isGroup = (def: ProtocolColumn): def is ColGroupDef => 'children' in def; + +const leafColumns = (defs: ProtocolColumn[]): ProtocolColDef[] => defs.flatMap(def => (isGroup(def) ? leafColumns(def.children) : [def])); + +const prefixColumn = (): ProtocolColDef => { + const column = leafColumns(getProtocolColumnDefs()).find(def => def.field === 'prefix'); + if (!column) throw new Error('prefix column is missing from the protocols table'); + return column; +}; + +describe('DetailsRSEProtocolsTable column sizing', () => { + it('grows the prefix column instead of truncating deep storage paths', () => { + const prefix = prefixColumn(); + // A fixed width pins the column regardless of viewport, which is what truncated prefixes. + expect(prefix.width).toBeUndefined(); + expect(prefix.flex).toBeGreaterThan(0); + expect(prefix.minWidth).toBeGreaterThanOrEqual(300); + }); + + it('wraps the prefix so long paths stay fully visible without widening the table', () => { + const prefix = prefixColumn(); + // Widening far enough for the longest paths would force horizontal scrolling instead. + expect(prefix.wrapText).toBe(true); + expect(prefix.autoHeight).toBe(true); + }); +}); diff --git a/test/fixtures/table-fixtures.ts b/test/fixtures/table-fixtures.ts index fd34d00c8..bc0fb7932 100644 --- a/test/fixtures/table-fixtures.ts +++ b/test/fixtures/table-fixtures.ts @@ -7,6 +7,7 @@ import { ReplicaState, RSEAttribute, RSEBlockState, + RSEDetailsProtocol, RSEProtocol, RSEType, RuleGrouping, @@ -267,6 +268,38 @@ export function fixtureRSEProtocol(): RSEProtocol { }; } +export function fixtureRSEDetailsProtocol(): RSEDetailsProtocol { + return { + scheme: faker.helpers.arrayElement(['srm', 'gsiftp', 'root', 'davs', 's3', 'file']), + hostname: faker.internet.domainName(), + port: faker.number.int({ min: 0, max: 1e4 }), + // Deployed prefixes are deep storage paths, hence the length + prefix: faker.helpers.arrayElement([ + '/tmp/rucio_rse/', + '/rucio/tmpdisk/rucio_tests/', + '/eos/atlas/atlasdatadisk/rucio/', + '/pnfs/rucio/disk-only/scratchdisk/', + '/pnfs/fnal.gov/usr/cms/WAX/11/store/', + '/dpm/in2p3.fr/home/cms/phedex/store/mc/RunIISummer20UL18/', + ]), + impl: 'rucio.rse.protocols.gfal.Default', + domains: { + lan: { + read: faker.number.int({ min: 0, max: 10 }), + write: faker.number.int({ min: 0, max: 10 }), + delete: faker.number.int({ min: 0, max: 10 }), + }, + wan: { + read: faker.number.int({ min: 0, max: 10 }), + write: faker.number.int({ min: 0, max: 10 }), + delete: faker.number.int({ min: 0, max: 10 }), + third_party_copy_read: faker.number.int({ min: 0, max: 10 }), + third_party_copy_write: faker.number.int({ min: 0, max: 10 }), + }, + }, + }; +} + export function fixtureRSEAttribute(): RSEAttribute { return { key: faker.lorem.words(2).replace(/\s/g, '-'),