Skip to content
603 changes: 438 additions & 165 deletions frontend/src/components/pages/rp-connect/pipeline/list.tsx

Large diffs are not rendered by default.

59 changes: 0 additions & 59 deletions frontend/src/components/pages/rp-connect/pipeline/status-badge.tsx

This file was deleted.

80 changes: 79 additions & 1 deletion frontend/src/components/pages/rp-connect/utils/yaml.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ConnectError } from '@connectrpc/connect';
import { toast } from 'sonner';
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
import { Document, parseDocument, stringify as yamlStringify } from 'yaml';
import { Document, parseDocument, parse as parseYaml, stringify as yamlStringify } from 'yaml';

import { schemaToConfig } from './schema';
// import { HANDLED_ARRAY_MERGE_PATHS } from '../types/constants';
Expand Down Expand Up @@ -514,3 +514,81 @@ export const getConnectTemplate = ({

return configToYaml(newConfigObject, spec);
};

// ============================================================================
// Config Component Parsing (used by pipeline list)
// ============================================================================

/** Extract the first key from an object, or undefined if not a valid object. */
const firstKey = (obj: unknown): string | undefined =>
obj && typeof obj === 'object' && !Array.isArray(obj) ? Object.keys(obj)[0] : undefined;

/** Extract child output names from a multi-output component (broker, switch, fallback). */
const parseMultiOutputs = (outputKey: string, value: unknown): string[] | undefined => {
// broker: broker.outputs[] is an array of output objects
if (outputKey === 'broker' && value && typeof value === 'object' && !Array.isArray(value) && 'outputs' in value) {
const items = (value as { outputs?: unknown[] }).outputs;
if (Array.isArray(items)) {
return items.map(firstKey).filter((k): k is string => !!k);
}
}

// switch: switch.cases[].output is a single output per case
if (outputKey === 'switch' && value && typeof value === 'object' && !Array.isArray(value) && 'cases' in value) {
const cases = (value as { cases?: { output?: unknown }[] }).cases;
if (Array.isArray(cases)) {
return cases.map((c) => firstKey(c.output)).filter((k): k is string => !!k);
}
}

// fallback: the value itself is an array of output objects
if (outputKey === 'fallback' && Array.isArray(value)) {
return value.map(firstKey).filter((k): k is string => !!k);
}

return;
};

type ParsedYamlConfig = {
input?: Record<string, unknown>;
output?: Record<string, unknown>;
pipeline?: { processors?: Record<string, unknown>[] };
};

type ParsedConfigComponents = {
input?: string;
processors: string[];
outputs: string[];
};

/** Parse a pipeline's configYaml to extract input, processor, and output component names. */
export const parseConfigComponents = (configYaml: string): ParsedConfigComponents => {
const empty: ParsedConfigComponents = { processors: [], outputs: [] };
if (!configYaml) {
return empty;
}

try {
const config = parseYaml(configYaml) as ParsedYamlConfig | null;
if (!config) {
return empty;
}

const processors = Array.isArray(config.pipeline?.processors)
? config.pipeline.processors.map(firstKey).filter((p): p is string => !!p)
: [];

const outputObj = config.output;
let outputs: string[] = [];
if (outputObj && typeof outputObj === 'object') {
const outputKey = firstKey(outputObj);
if (outputKey) {
outputs = parseMultiOutputs(outputKey, outputObj[outputKey]) ?? [outputKey];
}
}

return { input: firstKey(config.input), processors, outputs };
} catch {
return empty;
}
};
100 changes: 100 additions & 0 deletions frontend/src/components/redpanda-ui/components/badge-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { cva, type VariantProps } from 'class-variance-authority';
import React from 'react';

import type { BadgeSize, BadgeVariant } from './badge';
import { Badge } from './badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
import { cn, type SharedProps } from '../lib/utils';

const badgeGroupVariants = cva('flex items-center', {
variants: {
gap: {
none: '',
xs: 'gap-0.5',
sm: 'gap-1',
md: 'gap-1.5',
lg: 'gap-2',
},
wrap: {
true: 'flex-wrap',
false: '',
},
},
defaultVariants: {
gap: 'sm',
wrap: false,
},
});

export interface BadgeGroupProps
extends React.ComponentProps<'div'>,
VariantProps<typeof badgeGroupVariants>,
SharedProps {
/** Maximum number of children to show before overflow (default: 3) */
maxVisible?: number;
/** Size of the overflow badge */
size?: BadgeSize;
/** Variant for the overflow badge */
variant?: BadgeVariant;
/** Custom render function for overflow tooltip content. Receives the overflow children as an array. If omitted, no tooltip is rendered. */
renderOverflowContent?: (overflowChildren: React.ReactNode[]) => React.ReactNode;
}

const BadgeGroup = React.forwardRef<HTMLDivElement, BadgeGroupProps>(
(
{
className,
gap,
wrap,
testId,
children,
maxVisible,
size = 'sm',
variant = 'neutral-inverted',
renderOverflowContent,
...props
},
ref
) => {
const childArray = React.Children.toArray(children);
const visibleChildren = childArray.slice(0, maxVisible);
const overflowChildren = childArray.slice(maxVisible);
const hasOverflow = overflowChildren.length > 0;

const overflowBadge = (
<Badge size={size} variant={variant}>
+{overflowChildren.length}
</Badge>
);

return (
<div
className={cn(badgeGroupVariants({ gap, wrap }), className)}
data-slot="badge-group"
data-testid={testId}
ref={ref}
{...props}
>
{visibleChildren}

{hasOverflow === true &&
(renderOverflowContent ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-pointer">{overflowBadge}</span>
</TooltipTrigger>
<TooltipContent>{renderOverflowContent(overflowChildren)}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
overflowBadge
))}
</div>
);
}
);

BadgeGroup.displayName = 'BadgeGroup';

export { BadgeGroup, badgeGroupVariants };
59 changes: 31 additions & 28 deletions frontend/src/components/redpanda-ui/components/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,62 +11,65 @@ const badgeVariants = cva(
variant: {
// === NEUTRAL (Grey - semantic tokens) ===
neutral:
'!border-transparent bg-background-inverse-subtle text-inverse [a&]:hover:bg-background-inverse-subtle-hover',
'neutral-inverted': '!border-transparent bg-surface-subtle [a&]:hover:bg-background-subtle-hover',
'border-transparent bg-background-inverse-subtle text-inverse [a&]:hover:bg-background-inverse-subtle-hover',
'neutral-inverted': 'border-transparent bg-surface-subtle [a&]:hover:bg-background-subtle-hover',
'neutral-outline': '!border-outline-inverse border [a&]:hover:bg-background-subtle-hover',

// === SIMPLE (Light grey - semantic tokens) ===
simple: 'text-secondary [a&]:hover:bg-background-subtle-hover',
'simple-inverted': 'border-transparent text-secondary [a&]:hover:bg-background-subtle-hover',
'simple-inverted': 'text-secondary [a&]:hover:bg-background-subtle-hover',
'simple-outline': '!border-outline-inverse border text-secondary [a&]:hover:bg-background-subtle-hover',

// === INFO (Blue - semantic tokens) ===
info: '!border-transparent bg-surface-informative text-inverse [a&]:hover:bg-surface-informative-hover',
info: 'border-transparent bg-surface-informative text-inverse [a&]:hover:bg-surface-informative-hover',
'info-inverted':
'!border-transparent bg-surface-informative-subtle text-info [a&]:hover:bg-surface-informative-subtle-hover',
'info-outline': '!border-outline-informative bg-transparent text-info [a&]:hover:bg-surface-informative-subtle',
'border-transparent bg-background-informative-subtle text-info-foreground [a&]:hover:bg-background-informative-subtle-hover',
'info-outline':
'border-outline-informative bg-transparent text-info [a&]:hover:bg-background-informative-subtle',

// === ACCENT (Brand Red - uses theme brand tokens) ===
accent: '!border-transparent bg-brand text-brand-foreground [a&]:hover:bg-surface-brand-hover',
'accent-inverted': '!border-transparent bg-background-brand-subtle text-brand [a&]:hover:bg-brand-alpha-default',
'accent-outline': '!border-outline-brand bg-transparent text-brand [a&]:hover:bg-brand-alpha-subtle',
accent: 'border-transparent bg-brand text-inverse [a&]:hover:bg-surface-brand-hover',
'accent-inverted':
'border-transparent bg-background-brand-subtle text-brand-foreground [a&]:hover:bg-brand-alpha-default',
'accent-outline': 'border-outline-brand bg-transparent text-brand [a&]:hover:bg-brand-alpha-subtle',

// === SUCCESS (Green - semantic tokens) ===
success: '!border-transparent bg-surface-success text-inverse [a&]:hover:bg-surface-success-hover',
success: 'border-transparent bg-surface-success text-inverse [a&]:hover:bg-surface-success-hover',
'success-inverted':
'!border-transparent bg-surface-success-subtle text-success [a&]:hover:bg-surface-success-subtle-hover',
'success-outline': '!border-outline-success bg-transparent text-success [a&]:hover:bg-surface-success-subtle',
'border-transparent bg-background-success-subtle text-success [a&]:hover:bg-background-success-subtle-hover',
'success-outline': 'border-outline-success bg-transparent text-success [a&]:hover:bg-background-success-subtle',

// === WARNING (Yellow/Orange - semantic tokens) ===
warning: '!border-transparent bg-surface-warning text-inverse [a&]:hover:bg-surface-warning-hover',
'warning-inverted': '!border-transparent bg-background-warning-subtle text-warning [a&]:hover:bg-warning-subtle',
'warning-outline': '!border-outline-warning bg-transparent text-warning [a&]:hover:bg-background-warning-subtle',
warning: 'border-transparent bg-surface-warning text-inverse [a&]:hover:bg-surface-warning-hover',
'warning-inverted':
'border-transparent bg-background-warning-subtle text-warning-foreground [a&]:hover:bg-warning-subtle',
'warning-outline': 'border-outline-warning bg-transparent text-warning [a&]:hover:bg-background-warning-subtle',

// === DISABLED (Muted - semantic tokens) ===
disabled: 'cursor-not-allowed !border-transparent bg-background-disabled text-disabled',
'disabled-inverted': 'cursor-not-allowed !border-transparent bg-surface-subtle text-disabled',
'disabled-outline': 'cursor-not-allowed !border-border-strong bg-transparent text-disabled',
disabled: 'cursor-not-allowed border-transparent bg-background-disabled text-disabled',
'disabled-inverted': 'cursor-not-allowed border-transparent bg-surface-subtle text-disabled',
'disabled-outline': 'cursor-not-allowed border-border-strong bg-transparent text-disabled',

// === DESTRUCTIVE/ERROR (Red - semantic tokens) ===
destructive:
'!border-transparent bg-surface-error text-inverse focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-surface-error-hover',
'border-transparent bg-surface-error text-inverse focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-surface-error-hover',
'destructive-inverted':
'!border-transparent bg-background-error-subtle text-destructive [a&]:hover:bg-destructive-subtle',
'border-transparent bg-background-error-subtle text-destructive [a&]:hover:bg-destructive-subtle',
'destructive-outline':
'!border-outline-error bg-transparent text-destructive [a&]:hover:bg-background-error-subtle',
'border-outline-error bg-transparent text-destructive [a&]:hover:bg-background-error-subtle',

// === SECONDARY (Dark Blue) ===
secondary: '!border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
'secondary-inverted': '!border-transparent bg-secondary/10 text-secondary [a&]:hover:bg-secondary/20',
'secondary-outline': '!border-secondary text-secondary [a&]:hover:bg-secondary/10',
secondary: 'border-transparent bg-secondary text-inverse [a&]:hover:bg-secondary/90',
'secondary-inverted': 'border-transparent bg-secondary/10 text-secondary [a&]:hover:bg-secondary/20',
'secondary-outline': 'border-secondary text-secondary [a&]:hover:bg-secondary/10',

// === PRIMARY (Indigo) ===
primary: '!border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
'primary-inverted': '!border-transparent bg-primary/10 text-primary [a&]:hover:bg-primary/20',
'primary-outline': '!border-primary text-primary [a&]:hover:bg-primary/10',
primary: 'border-transparent bg-primary text-inverse [a&]:hover:bg-primary/90',
'primary-inverted': 'border-transparent bg-primary/10 text-primary [a&]:hover:bg-primary/20',
'primary-outline': 'border-primary text-primary [a&]:hover:bg-primary/10',

// === OUTLINE (generic) ===
outline: '!border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
outline: 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
size: {
// Small: 20px height (from Figma)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/redpanda-ui/components/banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ const bannerVariants = cva(
{
variants: {
variant: {
secondary: 'bg-secondary text-secondary-foreground',
secondary: 'bg-secondary text-inverse',
accent: 'bg-accent text-accent-foreground',
muted: 'bg-muted text-muted-foreground',
primary: 'bg-primary text-primary-foreground',
primary: 'bg-primary text-inverse',
},
},
defaultVariants: {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/redpanda-ui/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ const buttonVariants = cva(
'disabled:bg-background-disabled disabled:text-disabled',
],
accent: [
'bg-brand text-brand-foreground shadow-xs',
'bg-brand text-inverse shadow-xs',
'hover:bg-surface-brand-hover',
'active:bg-surface-brand-pressed',
'disabled:bg-background-disabled disabled:text-disabled',
],
destructive: [
'bg-destructive text-destructive-foreground shadow-xs',
'bg-destructive text-inverse shadow-xs',
'hover:bg-surface-error-hover',
'active:bg-surface-error-pressed',
'focus-visible:ring-destructive',
Expand Down
Loading
Loading