diff --git a/src/pages/usage/components/resource-export-data.tsx b/src/pages/usage/components/resource-export-data.tsx index 977a71c7..a609032d 100644 --- a/src/pages/usage/components/resource-export-data.tsx +++ b/src/pages/usage/components/resource-export-data.tsx @@ -237,12 +237,14 @@ const ResourceExportData: React.FC = (props) => { // Normalize the date bucket to a plain calendar day (drop the ``T00:00:00`` // the hourly ``metered_usage`` carries) so the export matches the Tokens - // tab's date-only format. The export always requests day granularity. + // tab's date-only format. Slice the ISO string rather than re-parsing with + // dayjs — a tz-offset timestamp would shift the calendar day on format. + // The export always requests day granularity. const formatRowDates = ( items: ResourceBreakdownItem[] ): ResourceBreakdownItem[] => items.map((i) => - i.date ? { ...i, date: dayjs(i.date).format('YYYY-MM-DD') } : i + i.date ? { ...i, date: String(i.date).slice(0, 10) } : i ); const rows: ResourceBreakdownItem[] = formatRowDates( diff --git a/src/pages/usage/instances-tab/index.tsx b/src/pages/usage/instances-tab/index.tsx index 5417d784..e83ddc32 100644 --- a/src/pages/usage/instances-tab/index.tsx +++ b/src/pages/usage/instances-tab/index.tsx @@ -51,7 +51,9 @@ type GroupKey = 'gpu_type' | 'instance' | 'user'; interface ResourceBreakdownExtraTab { key: string; labelId: string; - useVisible?: () => boolean; + // Plain function (not a hook) — called during render to gate the tab; the + // plugin reads any runtime state non-reactively (Rules of Hooks). + isVisible?: (ctx: { scope: Scope }) => boolean; Component: React.ComponentType<{ tab: 'gpu-instances' | 'storage'; dateRange: [dayjs.Dayjs, dayjs.Dayjs]; @@ -145,15 +147,11 @@ const GpuInstancesTab: React.FC = () => { user_groups: userGroups } = useResourceMeta(scope); - // Enterprise-provided extra bottom sub-tabs (empty in the OSS build). Call - // each descriptor's ``useVisible`` in a stable order — the descriptor list is - // registered once at plugin init, so its length never changes (rules of - // hooks). + // Enterprise-provided extra bottom sub-tabs (empty in the OSS build). + // Visibility is a plain ``isVisible(ctx)`` function, evaluated at render — + // no hooks in a loop. const extraBreakdownTabs: ResourceBreakdownExtraTab[] = getGPUStackPlugin()?.usage?.resourceBreakdownExtraTabs ?? []; - const extraTabVisible = extraBreakdownTabs.map( - (t) => t.useVisible?.() ?? true - ); // The daily chart fetches group_by=date here; each bottom table owns its own // fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any @@ -528,7 +526,7 @@ const GpuInstancesTab: React.FC = () => { // Enterprise Organization breakdown sub-tab(s) — appended after the // built-in tabs; nothing here in the OSS build. ...extraBreakdownTabs - .filter((_, i) => extraTabVisible[i]) + .filter((t) => (t.isVisible ? t.isVisible({ scope }) : true)) .map((t) => ({ key: t.key, label: intl.formatMessage({ id: t.labelId }), diff --git a/src/pages/usage/storage-tab/index.tsx b/src/pages/usage/storage-tab/index.tsx index d58ac4d6..ca64dd06 100644 --- a/src/pages/usage/storage-tab/index.tsx +++ b/src/pages/usage/storage-tab/index.tsx @@ -54,7 +54,9 @@ type GroupKey = 'volume' | 'user'; interface ResourceBreakdownExtraTab { key: string; labelId: string; - useVisible?: () => boolean; + // Plain function (not a hook) — called during render to gate the tab; the + // plugin reads any runtime state non-reactively (Rules of Hooks). + isVisible?: (ctx: { scope: Scope }) => boolean; Component: React.ComponentType<{ tab: 'gpu-instances' | 'storage'; dateRange: [dayjs.Dayjs, dayjs.Dayjs]; @@ -140,15 +142,11 @@ const StorageTab: React.FC = () => { user_groups: userGroups } = useResourceMeta(scope); - // Enterprise-provided extra bottom sub-tabs (empty in the OSS build). Call - // each descriptor's ``useVisible`` in a stable order — the descriptor list is - // registered once at plugin init, so its length never changes (rules of - // hooks). + // Enterprise-provided extra bottom sub-tabs (empty in the OSS build). + // Visibility is a plain ``isVisible(ctx)`` function, evaluated at render — + // no hooks in a loop. const extraBreakdownTabs: ResourceBreakdownExtraTab[] = getGPUStackPlugin()?.usage?.resourceBreakdownExtraTabs ?? []; - const extraTabVisible = extraBreakdownTabs.map( - (t) => t.useVisible?.() ?? true - ); // Bumped on any filter change to snap every mounted table back to page 1; // each table owns its own page/sort state otherwise. @@ -508,7 +506,7 @@ const StorageTab: React.FC = () => { // Enterprise Organization breakdown sub-tab(s) — appended after the // built-in tabs; nothing here in the OSS build. ...extraBreakdownTabs - .filter((_, i) => extraTabVisible[i]) + .filter((t) => (t.isVisible ? t.isVisible({ scope }) : true)) .map((t) => ({ key: t.key, label: intl.formatMessage({ id: t.labelId }), diff --git a/src/pages/usage/token-tab/components/breakdown-tabs.tsx b/src/pages/usage/token-tab/components/breakdown-tabs.tsx index e8fd8c83..ba88ee74 100644 --- a/src/pages/usage/token-tab/components/breakdown-tabs.tsx +++ b/src/pages/usage/token-tab/components/breakdown-tabs.tsx @@ -8,14 +8,14 @@ import ModelsTable from '../tables/models-table'; import UsersTable from '../tables/users-table'; // A breakdown sub-tab contributed by a plugin (e.g. the enterprise -// Organization tab). ``useVisible`` is a React hook the host calls -// unconditionally per descriptor (stable array length → hook-safe) so the -// plugin can gate visibility on its own runtime state (e.g. platform-wide -// "All" context). +// Organization tab). ``isVisible`` is a PLAIN function (not a hook) called +// during render to gate the tab on the current context — the plugin reads any +// runtime state it needs non-reactively (e.g. the selected-org from storage), +// so the host never calls a hook in a loop (Rules of Hooks). export interface BreakdownExtraTab { key: string; labelId: string; - useVisible?: () => boolean; + isVisible?: (ctx: { scope: string }) => boolean; Component: React.ComponentType<{ filters: BreakdownFilters; dateRange: { start_date: string; end_date: string }; @@ -39,17 +39,11 @@ const BreakdownTabs: React.FC<{ const extraTabs: BreakdownExtraTab[] = getGPUStackPlugin()?.usage?.breakdownExtraTabs ?? []; - // Call each descriptor's visibility hook here (outside useMemo) so React's - // rules of hooks hold; the plugin's array is stable, so call order is too. - const extraVisible = extraTabs.map((tab) => - tab.useVisible ? tab.useVisible() : true - ); const items = useMemo(() => { const extraItems = extraTabs - .map((tab, index) => ({ tab, visible: extraVisible[index] })) - .filter(({ visible }) => visible) - .map(({ tab }) => { + .filter((tab) => (tab.isVisible ? tab.isVisible({ scope }) : true)) + .map((tab) => { const Component = tab.Component; return { key: tab.key, @@ -122,16 +116,7 @@ const BreakdownTabs: React.FC<{ return true; }) .concat(extraItems); - }, [ - filters, - dateRange, - pageResetKey, - refreshKey, - scope, - extraTabs, - extraVisible, - intl - ]); + }, [filters, dateRange, pageResetKey, refreshKey, scope, extraTabs, intl]); return (