Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ declare module '@tanstack/react-router' {
icon?: LucideIcon;
/** Render the route with minimal chrome (no page header/footer/padding). */
fullscreen?: boolean;
/** Route has its own title bar: the app header shows only the breadcrumb row. */
breadcrumbOnlyHeader?: boolean;
}
}

Expand Down
79 changes: 75 additions & 4 deletions frontend/src/components/layout/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { useLocation, useMatchRoute } from '@tanstack/react-router';
import { GitHubIcon, SlackIcon, TwitterIcon } from 'components/icons';
import { useLayoutEffect, useRef } from 'react';

import { isEmbedded } from '../../config';
import env, { getBuildDate, IsCI, IsDev } from '../../utils/env';
Expand Down Expand Up @@ -54,17 +55,87 @@ export const VersionInfo = () => {
);
};

/** Distance from the document top to the element, scroll-independent. */
const measureDocumentTop = (target: HTMLElement): number => {
let top = 0;
for (let el: HTMLElement | null = target; el; ) {
top += el.offsetTop;
el = el.offsetParent instanceof HTMLElement ? el.offsetParent : null;
}
return top;
};

/** Space the ancestors (up to and including <body>) keep below the element. */
const measureReservedBelow = (target: HTMLElement): number => {
let reserved = 0;
for (let el: HTMLElement | null = target; el && el !== document.documentElement; el = el.parentElement) {
const style = getComputedStyle(el);
reserved += Number.parseFloat(style.marginBottom) || 0;
if (el !== target) {
reserved += (Number.parseFloat(style.paddingBottom) || 0) + (Number.parseFloat(style.borderBottomWidth) || 0);
}
}
return reserved;
};

// Measured rather than a CSS flex chain: in embedded mode the wrappers above
// #mainLayout belong to the host app.
const stretchLayoutToViewportBottom = (layoutEl: HTMLElement) => {
const viewportHeight = document.documentElement.clientHeight;
const minHeight = Math.round(viewportHeight - measureDocumentTop(layoutEl) - measureReservedBelow(layoutEl));
const value = minHeight > 0 ? `${minHeight}px` : '';
if (layoutEl.style.minHeight !== value) {
layoutEl.style.minHeight = value;
}
};

/** Pins the footer to the viewport bottom on short pages by stretching `#mainLayout`. */
const useBottomPinnedFooter = (hidden: boolean) => {
const footerRef = useRef<HTMLElement | null>(null);

useLayoutEffect(() => {
const layoutEl = hidden ? null : footerRef.current?.closest<HTMLElement>('#mainLayout');
if (!layoutEl) {
return;
}

const update = () => stretchLayoutToViewportBottom(layoutEl);

update();
const observer = new ResizeObserver(update);
observer.observe(document.documentElement);
observer.observe(layoutEl);
// Re-measure when useCancelHostGutters adjusts the layout's margins post-mount.
const mutationObserver = new MutationObserver(update);
mutationObserver.observe(layoutEl, { attributeFilter: ['style'], attributes: true });
window.addEventListener('resize', update);
return () => {
observer.disconnect();
mutationObserver.disconnect();
window.removeEventListener('resize', update);
layoutEl.style.minHeight = '';
};
}, [hidden]);

return footerRef;
};

export const AppFooter = () => {
const location = useLocation();
const matchRoute = useMatchRoute();
const isAgentPage = matchRoute({ to: '/agents/$id' });

// Hide footer on AI agent inspector tab
let hidden = false;
if (isAgentPage) {
const searchParams = new URLSearchParams(location.searchStr ?? '');
if (searchParams.get('tab') === 'inspector') {
return null;
}
hidden = searchParams.get('tab') === 'inspector';
}

const footerRef = useBottomPinnedFooter(hidden);

if (hidden) {
return null;
}

const gitHub = (link: string, title: string) => (
Expand All @@ -76,7 +147,7 @@ export const AppFooter = () => {
);

return (
<footer className="footer">
<footer className="footer" ref={footerRef}>
{/* Social Media Links */}
<div className="links">
{isEmbedded()
Expand Down
9 changes: 4 additions & 5 deletions frontend/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,11 @@ function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeade

function AppPageHeader({ breadcrumbOnly = false }: { breadcrumbOnly?: boolean }) {
useApiStoreHook((s) => s.userData); // re-render when userData changes
// Fullscreen routes (e.g. the SQL studio) carry their own title bar/toolbar, so
// they never want the title+actions row — only the breadcrumb. Robust to which
// layout branch renders the header (standalone vs embedded misdetection).
// Routes with their own title bar (fullscreen or breadcrumbOnlyHeader staticData)
// get only the breadcrumb row, regardless of which layout branch renders this.
const matches = useMatches();
const isFullscreenRoute = matches.some((m) => m.staticData.fullscreen);
const hideTitleRow = breadcrumbOnly || isFullscreenRoute;
const routeOwnsTitleRow = matches.some((m) => m.staticData.fullscreen || m.staticData.breadcrumbOnlyHeader);
const hideTitleRow = breadcrumbOnly || routeOwnsTitleRow;
const showRefresh = useShouldShowRefresh();
const shouldHideHeader = useShouldHideHeader();
const useNewSidebar = !isEmbedded();
Expand Down
98 changes: 66 additions & 32 deletions frontend/src/components/pages/rp-connect/pipeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ import { Tabs, TabsList, TabsTrigger } from 'components/redpanda-ui/components/t
import { cn } from 'components/redpanda-ui/lib/utils';
import { LogExplorer } from 'components/ui/connect/log-explorer';
import { DeleteResourceAlertDialog } from 'components/ui/delete-resource-alert-dialog';
import { ExpandedPageToggle } from 'components/ui/expanded-page-toggle';
import { LintHintList } from 'components/ui/lint-hint/lint-hint-list';
import { YamlEditor } from 'components/ui/yaml/yaml-editor';
import { isEmbedded, isFeatureFlagEnabled, isServerless } from 'config';
import { useExpandedPageMode } from 'hooks/use-expanded-page-mode';
import { useRefFormDialog } from 'hooks/use-ref-form-dialog';
import { KeyRound, LayoutGrid, Plus, User, Zap } from 'lucide-react';
import type { editor } from 'monaco-editor';
Expand Down Expand Up @@ -1109,6 +1111,14 @@ function PipelinePageContent() {
const isViewVisualLane = mode === 'view' && activeViewLane === 'visual';
const isEditVisualLane = mode !== 'view' && activeEditLane === 'visual';
const showSidebar = !(isViewVisualLane || isEditVisualLane);
const showViewTabs = mode === 'view' && Boolean(pipeline);
const showEditTabs = mode !== 'view' && isVisualEditorEnabled;

const {
expanded,
toggleExpanded,
ref: expandedModeRef,
} = useExpandedPageMode({ storageKey: 'rp-pipeline-editor-mode' });

// Open the YAML lane and reveal a node: explicit id, else the selected node. Routes per mode.
const goToYamlNode = useCallback(
Expand All @@ -1132,9 +1142,13 @@ function PipelinePageContent() {
return (
// Viewport-bounded height (7rem = app header + pt-8) so a tall lane scrolls within the framed panel.
// The -ml-3.5/pl-3.5 pair keeps the back button's overhang inside the overflow-x-clip region.
<div className="-ml-3.5 flex h-[calc(100dvh-7rem)] min-h-[500px] min-w-0 flex-col gap-4 overflow-x-clip pl-3.5">
<div
className="-ml-3.5 flex h-[calc(100dvh-7rem)] min-h-[500px] min-w-0 flex-col gap-4 overflow-x-clip pl-3.5"
ref={expandedModeRef}
>
{mode === 'view' && pipeline ? (
<PipelineViewHeader
expanded={expanded}
onBack={handleCancel}
onViewDetails={() => setIsViewConfigDialogOpen(true)}
pipeline={pipeline}
Expand All @@ -1150,6 +1164,7 @@ function PipelinePageContent() {
) : null}
{mode !== 'view' ? (
<PipelineEditHeader
expanded={expanded}
form={form}
hasUnsavedChanges={hasUnsavedChanges}
isSaving={isSaving}
Expand All @@ -1162,38 +1177,54 @@ function PipelinePageContent() {
) : null}
{/* Editor frame flexes to fill the column; the tips strip is pinned just beneath so it stays visible. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
{/* Framed panel: the lane tabs sit flush at the top, their underline as the internal divider. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border!">
{mode === 'view' && pipeline ? (
<Tabs value={activeViewLane}>
{/* Full-width list (so the underline divider spans) with content-width triggers so tabs pack left. */}
<TabsList className="[&_[data-slot=tabs-trigger]]:w-auto" variant="underline">
<TabsTrigger onClick={() => setActiveViewLane('monitor')} value="monitor" variant="underline">
Monitor
</TabsTrigger>
<TabsTrigger onClick={() => goToYamlNode()} value="configuration" variant="underline">
YAML
</TabsTrigger>
{isVisualEditorEnabled ? (
<TabsTrigger onClick={() => setActiveViewLane('visual')} value="visual" variant="underline">
{/* Boxed: rounded frame. Fullscreen: flush sides; top/bottom borders stay so
clipped scrollable content keeps a visible edge. */}
<div
className={cn(
'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border border-border! transition-[border-radius,border-color] duration-300 ease-in-out',
expanded ? 'rounded-none border-x-transparent!' : 'rounded-lg'
)}
>
{/* Lane tabs + fullscreen toggle at the surface's top-right corner; pr-12
keeps triggers clear of the overlaid toggle. */}
<div className="relative shrink-0">
{showViewTabs ? (
<Tabs value={activeViewLane}>
<TabsList className="pr-12 [&_[data-slot=tabs-trigger]]:w-auto" variant="underline">
<TabsTrigger onClick={() => setActiveViewLane('monitor')} value="monitor" variant="underline">
Monitor
</TabsTrigger>
<TabsTrigger onClick={() => goToYamlNode()} value="configuration" variant="underline">
YAML
</TabsTrigger>
{isVisualEditorEnabled ? (
<TabsTrigger onClick={() => setActiveViewLane('visual')} value="visual" variant="underline">
Visual
</TabsTrigger>
) : null}
</TabsList>
</Tabs>
) : null}
{showEditTabs ? (
<Tabs value={activeEditLane}>
<TabsList className="pr-12 [&_[data-slot=tabs-trigger]]:w-auto" variant="underline">
<TabsTrigger onClick={() => goToYamlNode()} value="yaml" variant="underline">
YAML
</TabsTrigger>
<TabsTrigger onClick={() => setActiveEditLane('visual')} value="visual" variant="underline">
Visual
</TabsTrigger>
) : null}
</TabsList>
</Tabs>
) : null}
{mode !== 'view' && isVisualEditorEnabled ? (
<Tabs value={activeEditLane}>
<TabsList className="[&_[data-slot=tabs-trigger]]:w-auto" variant="underline">
<TabsTrigger onClick={() => goToYamlNode()} value="yaml" variant="underline">
YAML
</TabsTrigger>
<TabsTrigger onClick={() => setActiveEditLane('visual')} value="visual" variant="underline">
Visual
</TabsTrigger>
</TabsList>
</Tabs>
) : null}
</TabsList>
</Tabs>
) : null}
{showViewTabs || showEditTabs ? null : (
// Tabless configurations keep the strip so the toggle/divider stay put.
<div className="!border-border h-9 border-b bg-background" />
)}
<div className="absolute inset-y-0 right-1.5 flex items-center">
<ExpandedPageToggle expanded={expanded} onToggle={toggleExpanded} />
</div>
</div>
{/* min-w-0 + overflow-hidden keep the editor region from propagating width upward. */}
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
{showSidebar ? (
Expand Down Expand Up @@ -1256,7 +1287,10 @@ function PipelinePageContent() {
</div>
</div>
{tipsContext ? (
<EditorTipsBar context={tipsContext} readOnly={mode === 'view'} slashMenuEnabled={isSlashMenuEnabled} />
// Match the header's fullscreen inset.
<div className={cn('transition-[padding] duration-300 ease-in-out', expanded && 'px-4')}>
<EditorTipsBar context={tipsContext} readOnly={mode === 'view'} slashMenuEnabled={isSlashMenuEnabled} />
</div>
) : null}
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ const BackButton = ({ onClick }: { onClick: () => void }) => (
</Button>
);

// Fullscreen insets the header while the panel below goes flush.
const headerClassName = (expanded: boolean | undefined) =>
cn('flex flex-col gap-3 transition-[padding] duration-300 ease-in-out', expanded && 'px-4');

// Inline-editable pipeline name, bound to the same form field as the settings dialog.
const EditableTitle = ({ form, placeholder }: { form: UseFormReturn<PipelineFormValues>; placeholder: string }) => (
<Controller
Expand Down Expand Up @@ -199,10 +203,12 @@ export function PipelineViewHeader({
pipeline,
onBack,
onViewDetails,
expanded,
}: {
pipeline: Pipeline;
onBack: () => void;
onViewDetails: () => void;
expanded?: boolean;
}) {
const navigate = useNavigate();
const name = pipeline.displayName || pipeline.id;
Expand All @@ -228,7 +234,7 @@ export function PipelineViewHeader({
];

return (
<header className="flex flex-col gap-3">
<header className={headerClassName(expanded)}>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<BackButton onClick={onBack} />
Expand Down Expand Up @@ -281,6 +287,7 @@ export function PipelineEditHeader({
onEditSettings,
isSaving,
hasUnsavedChanges,
expanded,
}: {
form: UseFormReturn<PipelineFormValues>;
mode: 'edit' | 'create';
Expand All @@ -290,6 +297,7 @@ export function PipelineEditHeader({
onEditSettings: () => void;
isSaving?: boolean;
hasUnsavedChanges?: boolean;
expanded?: boolean;
}) {
const description = useWatch({ control: form.control, name: 'description' })?.trim();
const units = useWatch({ control: form.control, name: 'computeUnits' });
Expand All @@ -301,7 +309,7 @@ export function PipelineEditHeader({
];

return (
<header className="flex flex-col gap-3">
<header className={headerClassName(expanded)}>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ export const PermissionsListTabNew: FC = () => {
return (
<>
<SecurityTabsNav />
<ListLayout className="my-4">
<ListLayout className="my-4 min-h-0">
<div className="text-muted-foreground text-sm sm:text-base">
<DescriptionWithHelp
short="Unified view of all principal permissions across your cluster."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export const RolesTabNew: FC = () => {
return (
<>
<SecurityTabsNav />
<ListLayout className="my-4">
<ListLayout className="my-4 min-h-0">
<div className="text-muted-foreground text-sm sm:text-base">
<NullFallbackBoundary>
<div className="mb-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export const UsersTabNew: FC = () => {
<>
<SecurityTabsNav />
<CreateUserDialog key={createDialogKey} onOpenChange={setIsCreateDialogOpen} open={isCreateDialogOpen} />
<ListLayout className="my-4">
<ListLayout className="my-4 min-h-0">
<div className="text-muted-foreground text-sm sm:text-base">
<DescriptionWithHelp short="SASL-SCRAM user accounts managed by your cluster." title="Users">
These users are SASL-SCRAM users managed by your cluster. View permissions for other authentication
Expand Down
Loading
Loading