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/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx
index 698d6b1a4..a5f9eeb1c 100644
--- a/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx
+++ b/src/component-library/pages/RSE/details/DetailsRSEProtocolsTable.tsx
@@ -2,150 +2,157 @@ 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',
+ 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,
+ },
+ ],
+ },
+ {
+ 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 ;
};
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, '-'),