Files
gpustack-ui/src/plugins/list-extra-columns.ts
T
gitlawrandjialin 26ce456bde feat: per-page extra-columns slot for list tables
Add `usePluginListColumns(pageKey)` so a registered plugin can splice
extra columns into a list page's columns hook keyed by page id. The
slot is wired into 11 list pages (Models, Model Routes, Clusters,
Cloud Credentials, MaaS Providers, Model Files, Model Instances,
GPU Service Instances / Public Keys / Storage / Storage Types) so any
consumer of the seam reaches every org-scoped list without per-page
plumbing.

The slot accepts either a static array or a hook function — the hook
form lets the registrant use React hooks to decide visibility without
the host having to evaluate it. Columns carry a `placement`:
`after-name` is the default (sits next to the row's identifying
column); `before-time` / `before-operation` are kept for back-compat
with the existing per-page `modelRoutes.extraColumns` slot.

SealTable-grid pages (Models / Model Routes / Clusters) absorb the
plugin column's span by shrinking the widest right-side columns so
the 24-unit grid stays balanced. Model Routes' `CREATE_TIME_MIN_SPAN`
drops from 3 to 2 because with two plugin columns active the grid
was 1 unit over and the action dropdown wrapped onto a new row; the
date string ellipsizes cleanly at the lower min.

ListItem types for Model Instances, MaaS Providers, Cloud Credentials,
and Model Files gain an optional `owner_principal_id` field — the
backend already emits it (denormalized from the parent resource), the
TS types just hadn't declared it.

The GPU Instances page splices the plugin column right before the
Cluster column (rather than after Name); the two read together since
Cluster narrows down to one Org.
2026-06-09 16:28:59 +08:00

83 lines
3.6 KiB
TypeScript

import { useMemo, useRef } from 'react';
import { getGPUStackPlugin } from './index';
// Generic per-page "extra columns" seam. Each list page (Models,
// Model Routes, Clusters, etc.) reads its own slot keyed by a stable
// page id and splices the returned columns into its column array.
// Plugins that aren't loaded — or that don't register a slot for the
// page — produce an empty list, so the host stays unchanged.
export type PluginListColumn = {
key: string;
// i18n message id used as the column title.
titleId: string;
// Optional SealTable grid span; antd-`Table` consumers ignore it.
span?: number;
// Where in the existing column order the entry lands. `after-name`
// (the default) is the natural slot for identifying columns like
// "Organization" that read alongside the row's name; the other two
// are kept for back-compat with the per-page slots that predate
// this generic seam (`modelRoutes.extraColumns` uses them for the
// quota-default cell). Each page's hook decides which placements
// it honors.
placement?: 'after-name' | 'before-operation' | 'before-time';
render: (record: any) => React.ReactNode;
};
// Plugins may register either a static array OR a function returning
// one. The function form lets the plugin use React hooks (e.g. to
// read jotai atoms for visibility) since the host calls it inside its
// own `useMemo`-wrapping hook below. The function must obey the rules
// of hooks — call the same hooks in the same order each render and
// short-circuit to `[]` rather than skipping hook calls when hidden.
export type PluginListColumnsEntry =
| PluginListColumn[]
| (() => PluginListColumn[]);
const readEntry = (pageKey: string): PluginListColumnsEntry | undefined => {
const slots = getGPUStackPlugin()?.listExtraColumns as
| Record<string, PluginListColumnsEntry>
| undefined;
return slots?.[pageKey];
};
// Host hook: each list page's columns hook calls this once with its
// page key. Returns `[]` when no plugin / no slot. The function-form
// entry is always called when present so its inner hooks run the same
// number of times every render — visibility logic must live inside
// the entry's return value, not around the call.
export const usePluginListColumns = (pageKey: string): PluginListColumn[] => {
const entry = readEntry(pageKey);
// `entry` is read once per render from a registry wired at boot, so
// its identity is stable across renders. The function form is
// invoked unconditionally so the same hooks run in the same order
// every render — visibility logic must live in the entry's return
// value, not around the call.
const fromFn = typeof entry === 'function' ? entry() : null;
// Belt-and-suspenders stability: if the plugin's hook returns an
// array with the same elements but a fresh outer reference (it can
// happen even when the plugin author tried to memoize), pin it to
// the previous reference so downstream `useMemo`s in every list
// page don't re-fire and rebuild their `columns` arrays. Cheap
// shallow check by length + element identity — same shape every
// call site already promises through the schema.
const prevRef = useRef<PluginListColumn[] | null>(null);
const stableFromFn = useMemo(() => {
if (!fromFn) return null;
const prev = prevRef.current;
if (
prev &&
prev.length === fromFn.length &&
prev.every((col, i) => col === fromFn[i])
) {
return prev;
}
prevRef.current = fromFn;
return fromFn;
}, [fromFn]);
return useMemo(() => {
if (!entry) return [];
if (typeof entry === 'function') return stableFromFn ?? [];
return entry;
}, [entry, stableFromFn]);
};