From operator-dashboard
Generate OpenShift Console operator dashboard from operator name and CRD discovery
How this command is triggered — by the user, by Claude, or both
Slash command
/operator-dashboard:generate-dashboard <operator-name> [--namespace <ns>] [--output-dir <dir>]Files this command reads when invoked
The summary Claude sees in its command listing — used to decide when to auto-load this command
## Name operator-dashboard:generate-dashboard ## Synopsis ## Description This command adds a new operator to an OpenShift Console dynamic plugin. It discovers the operator's CRDs via cluster API (e.g. `oc api-resources`), then generates the dashboard page, one table component per resource kind using the shared ResourceTable pattern, operator CSS, and extends ResourceInspect for the detail view. Optional parameters allow scoping to a namespace and writing output to a custom directory. The template components in `skills/dashboard-templates/` define the CRD models (`.ts`) and list/detail U...
operator-dashboard:generate-dashboard
/operator-dashboard:generate-dashboard <operator-name> [--namespace <ns>] [--output-dir <dir>]
This command adds a new operator to an OpenShift Console dynamic plugin. It discovers the operator's CRDs via cluster API (e.g. oc api-resources), then generates the dashboard page, one table component per resource kind using the shared ResourceTable pattern, operator CSS, and extends ResourceInspect for the detail view. Optional parameters allow scoping to a namespace and writing output to a custom directory. The template components in skills/dashboard-templates/ define the CRD models (.ts) and list/detail UI components (ResourceTable, ResourceInspect) that are adapted to the target operator's API group, version, kind, and printer columns.
Before starting, read the skill at
skills/dashboard-templates/SKILL.md. It documents every template component, explains when to use each one, and describes the patterns to follow when adapting them to a new operator's CRDs.
Before implementing, review these common mistakes that break dashboard quality:
Button & Actions:
ResourceTableRowActions components in table files<button> tags — use PatternFly <Button> componentclassName on <Link> wrapper — put it on <Button>useDeleteModal inside .map() loops<Button variant="primary"> for Inspect (blue)<Button variant="danger"> for Delete (red)ResourceTableRowActions from ./ResourceTablePatternFly 6 APIs:
--pf-v6-global--*<Title> componentdate={new Date(...)} with Timestampvariant prop for Label status colors--pf-t--global--* (PatternFly 6 semantic tokens)titleText, icon={SearchIcon}, headingLeveltimestamp={resource.metadata?.creationTimestamp} with Timestampstatus prop: status="success|danger|warning"Page Layout:
size="lg" for main page titlesco-m-*, table-hover)size="xl" for page titles (<Title headingLevel="h1" size="xl">)console-plugin-template__*)Step 0 — Verify API groups on the cluster (REQUIRED before any coding)
oc api-resources | grep -i <operator-keyword>
group and version for every K8s model and GVK. Format <group>/<version> (e.g. nfd.openshift.io/v1 → group nfd.openshift.io, version v1). Entry with no slash (e.g. v1) means core group ("").true means resource needs selectedProject and Namespace column; false means cluster-scoped (no namespace in inspect URL, no selectedProject).Step 1 — Directories
mkdir -p src/hooks src/components/crdsStep 2 — src/hooks/useOperatorDetection.ts
useK8sModel with { group, version, kind } for the primary resource.OperatorStatus, OperatorInfo, <OPERATOR>_OPERATOR_INFO, and useOperatorDetection().useK8sModel returns [model, inFlight] where inFlight is true while loading. Check if (inFlight) return 'loading' — NOT if (!inFlight).Step 3 — src/components/crds/index.ts
K8sResourceCommon with optional spec/status.Step 4 — src/components/crds/Events.ts
plural: 'Kind' to RESOURCE_TYPE_TO_KIND for each new resource (so events can resolve involvedObject kind from resource type).Step 5 — src/components/OperatorNotInstalled.tsx
<EmptyState titleText={...} icon={SearchIcon} headingLevel="h4"><EmptyStateBody>...</EmptyStateBody></EmptyState>.<Title> component child.6a. Step 5b — src/components/ResourceTable.tsx (shared component)
columns, rows, loading, error, emptyStateTitle, emptyStateBody, selectedProject, data-test..console-plugin-template__loader with three .console-plugin-template__loader-dot), error Alert, EmptyState with props API (titleText, icon={SearchIcon}, headingLevel="h4"), or table with plugin-prefixed classes.console-plugin-template__table, __table-th, __table-td, __table-tr classes (NO co-m-* or table-hover).import { Link } from 'react-router-dom';
import { Button } from '@patternfly/react-core';
import { useDeleteModal } from '@openshift-console/dynamic-plugin-sdk';
interface ResourceTableRowActionsProps {
resource: any;
inspectHref: string;
}
export const ResourceTableRowActions: React.FC<ResourceTableRowActionsProps> = ({
resource,
inspectHref,
}) => {
const { t } = useTranslation('plugin__console-plugin-template');
const launchDeleteModal = useDeleteModal(resource);
return (
<div className="console-plugin-template__action-buttons">
<Link to={inspectHref}>
<Button className="console-plugin-template__action-inspect" variant="primary" size="sm">
{t('Inspect')}
</Button>
</Link>
<Button
className="console-plugin-template__action-delete"
variant="danger"
size="sm"
onClick={launchDeleteModal}
>
{t('Delete')}
</Button>
</div>
);
};
<Button> component (NOT plain <button> tag)className goes on <Button> (NOT on wrapping <Link>)variant prop: variant="primary" (blue Inspect), variant="danger" (red Delete)useDeleteModal called once per row (in this component), NOT in table's .map() loopStep 6 — Table components (src/components/<KindPlural>Table.tsx)
./ResourceTable). One file per resource kind.import { Link } from 'react-router-dom';, import { Label } from '@patternfly/react-core';, import { useK8sWatchResource, Timestamp } from '@openshift-console/dynamic-plugin-sdk';, import { ResourceTable, ResourceTableRowActions } from './ResourceTable';{ title, width? } — Name, Namespace (if namespaced), then columns from algorithm (additionalPrinterColumns priority 0; priority 1 if total ≤ 8; fallback: Status from status.conditions[type=Ready], Age from metadata.creationTimestamp), then Actions.useK8sWatchResource list; each row cells:
<Link key="name" to={inspectHref}>{name}</Link> (no <a href>).<Label key="status" status={statusColor}>{t(statusText)}</Label> with status prop: "success" for ready, "danger" for not ready, "warning" for unknown), Age (<Timestamp key="age" timestamp={resource.metadata?.creationTimestamp} />).<ResourceTableRowActions key="actions" resource={obj} inspectHref={inspectHref} /> (ResourceTableRowActions internally calls useDeleteModal, do NOT call it in .map()).!loaded && !loadError), error (loadError?.message), emptyStateTitle, emptyStateBody, selectedProject (namespaced only), data-test.selectedProject, inspect href /<page>/inspect/<plural>/${namespace}/${name}.selectedProject, inspect href /<page>/inspect/<plural>/${name}.ResourceTableRowActions from ./ResourceTable (includes both Inspect AND Delete buttons). Do NOT create inline action components. Do NOT use VirtualizedTable, <a href>, or custom button logic.Step 6b — Expandable Row Components (if relationships defined)
src/components/ExpandableResourceTable.tsx with props: columns, rows (each row: key, cells, isExpanded, onToggle, expandedContent), loading, error, emptyStateTitle, emptyStateBody, selectedProject, data-test. First column is expand toggle (AngleRightIcon/AngleDownIcon); expanded row <tr><td colSpan={columns.length + 1}>...</td></tr>.useK8sWatchResource, filters by matchField/matchType (field | ownerRef | label), and renders ResourceTable or "No related s" when empty. Children must be fetched lazily only when the parent row is expanded.expandedRows: Set<string>; each row's expandedContent is the child table component with parentName/parentNamespace.Step 7 — CSS (src/components/<operator-short-name>.css)
var(--pf-t--global--*)). NEVER use hex colors or old --pf-v6-global--* format..console-plugin-template__inspect-page {
padding: var(--pf-t--global--spacer--lg) var(--pf-t--global--spacer--xl);
}
.console-plugin-template__dashboard-cards {
display: flex;
flex-direction: column;
gap: var(--pf-t--global--spacer--xl);
}
.console-plugin-template__resource-card {
margin-bottom: 0;
}
.console-plugin-template__resource-table {
overflow: hidden;
}
.console-plugin-template__table-responsive {
overflow-x: auto;
}
.console-plugin-template__table {
border-collapse: collapse;
width: 100%;
background-color: var(--pf-t--global--background--color--primary--default);
}
.console-plugin-template__table-th {
padding: var(--pf-t--global--spacer--sm) var(--pf-t--global--spacer--md);
text-align: left;
vertical-align: middle;
background-color: var(--pf-t--global--background--color--secondary--default);
border-bottom: 1px solid var(--pf-t--global--border--color--default);
font-weight: var(--pf-t--global--font--weight--body--bold);
}
.console-plugin-template__table-tr {
border-bottom: 1px solid var(--pf-t--global--border--color--default);
}
.console-plugin-template__table-tr:hover {
background-color: var(--pf-t--global--background--color--secondary--hover);
}
.console-plugin-template__table-td {
padding: var(--pf-t--global--spacer--sm) var(--pf-t--global--spacer--md);
text-align: left;
vertical-align: middle;
word-wrap: break-word;
overflow: hidden;
}
.console-plugin-template__table-message {
padding: var(--pf-t--global--spacer--lg);
}
.console-plugin-template__loader {
display: flex;
gap: var(--pf-t--global--spacer--sm);
align-items: center;
justify-content: center;
padding: var(--pf-t--global--spacer--lg);
}
.console-plugin-template__loader-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--pf-t--global--color--brand--default);
animation: console-plugin-template-loader-bounce 1.2s infinite ease-in-out;
}
.console-plugin-template__loader-dot:nth-child(1) {
animation-delay: 0s;
}
.console-plugin-template__loader-dot:nth-child(2) {
animation-delay: 0.2s;
}
.console-plugin-template__loader-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes console-plugin-template-loader-bounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.console-plugin-template__action-buttons {
display: flex;
gap: var(--pf-t--global--spacer--xs);
flex-wrap: nowrap;
}
.console-plugin-template__action-inspect {
flex-shrink: 0;
}
.console-plugin-template__action-delete {
flex-shrink: 0;
}
CRITICAL: Do NOT add background-color/border-color CSS for buttons. Button colors come from PatternFly's variant prop (variant="primary" for blue Inspect, variant="danger" for red Delete)..console-plugin-template__expand-toggle, .console-plugin-template__expanded-row, .console-plugin-template__expanded-content, .console-plugin-template__child-table, .console-plugin-template__no-children (use token variables for colors/spacing).co-m-*, table-hover, inline style attributes, hex colors, or old --pf-v6-global--* variable format. Keyframes names must be kebab-case.Step 7b — Optional: Overview dashboard
Step 8 — Operator page (src/<OperatorShortName>Page.tsx)
import { Title, Card, CardTitle, CardBody, Spinner } from '@patternfly/react-core';, import Helmet from 'react-helmet';, import { useTranslation } from 'react-i18next';, import { useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk';, import { useOperatorDetection, <OPERATOR>_OPERATOR_INFO } from './hooks/useOperatorDetection';, import { OperatorNotInstalled } from './components/OperatorNotInstalled';, table imports, CSS import.const selectedProject = activeNamespace === '#ALL_NS#' ? '#ALL_NS#' : activeNamespace;
const pageTitle = t('<operator-display-name>');
if (operatorStatus === 'loading') {
return (
<>
<Helmet><title>{pageTitle}</title></Helmet>
<div className="console-plugin-template__inspect-page">
<Spinner size="lg" aria-label={t('Loading...')} />
</div>
</>
);
}
if (operatorStatus === 'not-installed') {
return (
<>
<Helmet><title>{pageTitle}</title></Helmet>
<div className="console-plugin-template__inspect-page">
<Title headingLevel="h1" size="xl">
{pageTitle}
</Title>
<OperatorNotInstalled operatorDisplayName={<OPERATOR>_OPERATOR_INFO.displayName} />
</div>
</>
);
}
return (
<>
<Helmet><title>{pageTitle}</title></Helmet>
<div className="console-plugin-template__inspect-page">
<Title
headingLevel="h1"
size="xl"
style={{ marginBottom: 'var(--pf-t--global--spacer--lg)' }}
>
{pageTitle}
</Title>
<div className="console-plugin-template__dashboard-cards">
<Card className="console-plugin-template__resource-card">
<CardTitle>{t('<ResourceKind Plural>')}</CardTitle>
<CardBody>
<<KindPlural>Table selectedProject={selectedProject} />
</CardBody>
</Card>
{/* Repeat Card for each resource kind */}
</div>
</div>
</>
);
size="xl" for page title (NOT size="lg"). Pass selectedProject only to namespaced tables. For cluster-scoped tables, do not pass selectedProject. For fixed-namespace operators, pass the fixed namespace string instead of selectedProject.export const <OperatorShortName>Page) and default (export default <OperatorShortName>Page).Step 9 — src/ResourceInspect.tsx (extend only)
'/cert-manager'). Cluster-scoped: component already handles 2-segment path (plural/name). Keep URL parsing and Card + Grid layout as-is.Step 10 — console-extensions.json
/<operator-short-name>, component $codeRef: "<OperatorShortName>Page.<OperatorShortName>Page" (e.g. CertManagerPage.CertManagerPage).["/<operator-short-name>/inspect"], component $codeRef: "ResourceInspect.ResourceInspect".console.navigation/section with id "plugins", insertAfter "observe". Add console.navigation/href with id <operator-short-name>, href /<operator-short-name>, section: "plugins" (do not use section "home").Step 11 — package.json
consolePlugin.exposedModules: "<OperatorShortName>Page": "./<OperatorShortName>Page". Add "ResourceInspect": "./ResourceInspect" only if not already present.Step 12 — Locales
locales/en/plugin__console-plugin-template.json. CRITICAL: Include "Inspect": "Inspect" and "Delete": "Delete" for action buttons. Also add page title, resource display names, empty states, error messages, "Plugins" if section added, "Loading..." for spinner aria-label. Do not remove existing keys.Step 13 — RBAC
charts/openshift-console-plugin/templates/rbac-clusterroles.yaml, add or append ClusterRoles and bindings: Reader (get, list, watch) and Admin (get, list, watch, delete) for the new API groups/resources. Template names: {{ template "openshift-console-plugin.name" . }}-<operator-short-name>-reader and -admin.Validation
oc api-resources output was used for all API groups/versions/scope.yarn build-dev; it must succeed. If "Invalid module export 'default' in extension [N] property 'component'" appears, fix console-extensions.json to use moduleName.exportName for every route component.yarn lint; fix any issues in src/ or CSS./<operator-short-name>; if "Operator not installed" when operator is installed, re-run oc api-resources and correct CRD models and useOperatorDetection.src/hooks/useOperatorDetection.tssrc/components/ResourceTable.tsx (CRITICAL: must export ResourceTableRowActions with Delete button)src/components/crds/index.tssrc/components/crds/Events.tssrc/components/OperatorNotInstalled.tsx (if created)src/components/<KindPlural>Table.tsx per kind (imports ResourceTableRowActions)src/components/ExpandableResourceTable.tsx (if relationships)src/components/<operator-short-name>.css (PatternFly 6 token variables)src/<OperatorShortName>Page.tsxsrc/ResourceInspect.tsx (extended)console-extensions.jsonpackage.jsonlocales/en/plugin__console-plugin-template.json (includes "Inspect" and "Delete")charts/openshift-console-plugin/templates/rbac-clusterroles.yamlBasic usage:
/operator-dashboard:generate-dashboard cert-manager
Scoped to a namespace:
/operator-dashboard:generate-dashboard external-secrets --namespace external-secrets
Custom output directory:
/operator-dashboard:generate-dashboard my-operator --output-dir ./src/dashboard
$1: The operator name used to discover its CRDs (and for naming the dashboard route and page).--namespace: Kubernetes namespace to scope CR listing. Default: all namespaces.--output-dir: Directory to write generated files into. Default: project root (e.g. ./ or ./src as appropriate).11plugins reuse this command
First indexed Jul 11, 2026
Showing the 6 earliest of 11 plugins
npx claudepluginhub stbenjam/ai-helpers --plugin operator-dashboard