From 73db81c36fb07542d664ea6276544605f612d0a3 Mon Sep 17 00:00:00 2001 From: Karakatiza666 Date: Tue, 14 Jul 2026 15:15:59 +0000 Subject: [PATCH] [web-console] Make side panels for connector errors and pipeline health resizable Signed-off-by: Karakatiza666 --- .../src/lib/components/layout/Drawer.svelte | 39 ++- .../lib/components/layout/InlineDrawer.svelte | 73 ++++-- .../layout/InlineDrawer.svelte.spec.ts | 54 ++++ .../components/layout/OverlayDrawer.svelte | 5 +- .../layout/OverlayDrawer.svelte.spec.ts | 40 +++ .../pipelines/PipelineEditLayout.svelte | 12 +- .../pipelines/editor/TabHealth.svelte | 95 +++---- .../pipelines/editor/TabPerformance.svelte | 235 +++++++++--------- .../src/lib/functions/common/percent.ts | 15 ++ .../(authenticated)/health/+page.svelte | 100 ++++---- 10 files changed, 435 insertions(+), 233 deletions(-) create mode 100644 js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte.spec.ts create mode 100644 js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte.spec.ts create mode 100644 js-packages/web-console/src/lib/functions/common/percent.ts diff --git a/js-packages/web-console/src/lib/components/layout/Drawer.svelte b/js-packages/web-console/src/lib/components/layout/Drawer.svelte index 54f41532dae..ca53ec42a1c 100644 --- a/js-packages/web-console/src/lib/components/layout/Drawer.svelte +++ b/js-packages/web-console/src/lib/components/layout/Drawer.svelte @@ -3,6 +3,7 @@ import OverlayDrawer from '$lib/components/layout/OverlayDrawer.svelte' import { useIsTablet } from '$lib/compositions/layout/useIsMobile.svelte' + import type { Percent } from '$lib/functions/common/percent' import type { Snippet } from '$lib/types/svelte' const isTablet = useIsTablet() @@ -10,16 +11,34 @@ let { open = $bindable(), side, + main, children, - width, + width = 'w-[700px]', onClose, - inlineClass = '' + inlineClass = '', + defaultSize = '30%', + minSize = '20%', + maxSize = '80%', + localStorageKey }: { open: boolean side: 'right' | 'left' + /** + * Always-visible content. On wide screens the drawer resizes against it + * via a draggable divider; on tablet/mobile the drawer overlays it. + */ + main: Snippet + /** Drawer content. */ children: Snippet - width: string + /** Overlay-mode drawer width on tablet/mobile, e.g. `w-[700px]`. */ + width?: string inlineClass?: string + /** Inline drawer pane size on open, as a percentage of the container. */ + defaultSize?: Percent + minSize?: Percent + maxSize?: Percent + /** localStorage key to persist the resized inline layout. */ + localStorageKey?: string /** * Called when the drawer requests dismissal (currently fired by the * modal-backdrop click in the tablet/mobile overlay variant). @@ -28,8 +47,8 @@ } = $props() - {#if isTablet.current} + {@render main()} {:else} - + {/if} diff --git a/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte index 5a2ccf07b0f..923872b9348 100644 --- a/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte +++ b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte @@ -1,38 +1,75 @@ -
-
+ {@render main()} + +{/snippet} +{#snippet drawerPane()} + -
+
{@render children()}
-
-
+ +{/snippet} + + + {#if open && side === 'left'} + {@render drawerPane()} + + {/if} + {@render mainPane()} + {#if open && side === 'right'} + + {@render drawerPane()} + {/if} + diff --git a/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte.spec.ts b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte.spec.ts new file mode 100644 index 00000000000..14a4d753738 --- /dev/null +++ b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte.spec.ts @@ -0,0 +1,54 @@ +/** + * Component tests for the resizable inline drawer. The drawer lays its + * always-visible `main` content next to a resizable `children` pane using + * PaneForge; a draggable separator (`[data-pane-resizer]`) appears only while + * the drawer is open. These tests run irrespective of viewport, since + * `InlineDrawer` is the wide-screen variant selected by `Drawer`. + */ + +import { createRawSnippet } from 'svelte' +import { describe, expect, it } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-svelte' +import InlineDrawer from './InlineDrawer.svelte' + +const textSnippet = (text: string) => + createRawSnippet(() => ({ render: () => `
${text}
` })) + +const renderDrawer = (props: { open: boolean; side: 'right' | 'left' }) => + render(InlineDrawer, { + ...props, + main: textSnippet('MAIN CONTENT'), + children: textSnippet('DRAWER CONTENT') + }) + +describe('InlineDrawer', () => { + it('shows only the main content and no resize handle when closed', async () => { + const { container } = renderDrawer({ open: false, side: 'right' }) + + await expect.element(page.getByText('MAIN CONTENT')).toBeInTheDocument() + // Closed: the main pane fills the container, so there is nothing to drag. + expect(container.querySelector('[data-pane-resizer]')).toBeNull() + expect(container.textContent).not.toContain('DRAWER CONTENT') + }) + + it('renders the drawer content and a draggable resize handle when open', async () => { + const { container } = renderDrawer({ open: true, side: 'right' }) + + await expect.element(page.getByText('DRAWER CONTENT')).toBeInTheDocument() + await expect.element(page.getByText('MAIN CONTENT')).toBeInTheDocument() + // The separator is what makes the drawer resizable — the crux of the feature. + // Remove the `{#if open}` guard around the resizer to see this assertion fail. + expect(container.querySelector('[data-pane-resizer]')).not.toBeNull() + }) + + it('places the drawer before the main content when anchored to the left', async () => { + const { container } = renderDrawer({ open: true, side: 'left' }) + + await expect.element(page.getByText('DRAWER CONTENT')).toBeInTheDocument() + // Declared `order` keeps the drawer pane first regardless of render order. + const panes = [...container.querySelectorAll('[data-pane]')] + expect(panes[0]?.textContent).toContain('DRAWER CONTENT') + expect(panes[1]?.textContent).toContain('MAIN CONTENT') + }) +}) diff --git a/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte index 06b6ade767c..7c0c8b76983 100644 --- a/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte +++ b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte @@ -63,7 +63,10 @@ diff --git a/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte.spec.ts b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte.spec.ts new file mode 100644 index 00000000000..b3fa7f285cd --- /dev/null +++ b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte.spec.ts @@ -0,0 +1,40 @@ +/** + * Component tests for the overlay drawer used on tablet/mobile. The drawer has + * a nominal width (e.g. `w-[700px]`) but must never grow wider than the screen, + * otherwise its contents scroll the page horizontally. These tests run at the + * browser's default narrow viewport, where the nominal width exceeds the screen. + */ + +import { createRawSnippet } from 'svelte' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import OverlayDrawer from './OverlayDrawer.svelte' + +const textSnippet = (text: string) => + createRawSnippet(() => ({ render: () => `
${text}
` })) + +const renderOverlay = () => + render(OverlayDrawer, { + open: true, + side: 'right', + modal: true, + width: 'w-[700px]', + class: 'p-4', + children: textSnippet('DRAWER CONTENT') + }) + +describe('OverlayDrawer', () => { + it('caps the drawer at the viewport width on narrow screens', async () => { + // Precondition: the nominal width is wider than the screen, so an + // uncapped drawer would overflow. Guard against a wide test viewport. + expect(window.innerWidth).toBeLessThan(700) + + const { container } = renderOverlay() + const dialog = container.querySelector('[role="dialog"]') as HTMLElement + expect(dialog).not.toBeNull() + + // The dialog shrinks to fit instead of forcing 700px. + // (Drop `max-w-full` from the dialog class to see this fail.) + expect(dialog.getBoundingClientRect().width).toBeLessThanOrEqual(window.innerWidth) + }) +}) diff --git a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte index c05940dd707..cd89e429947 100644 --- a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte +++ b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte @@ -345,7 +345,11 @@ {/if} - + {#if pipelineBannerMessage}
@@ -353,7 +357,11 @@ {/if} - + {#if pipeline.current}
-
- pipelineStatus(type).severity} - getBarColor={(type, h) => pipelineStatus(type).barColor(h)} - getStatusStyle={(type) => pipelineStatus(type).statusStyle} - selectedBars={selectedEventTimestamp - ? { from: selectedEventTimestamp.from, to: selectedEventTimestamp.to } - : null} - > - - {#if !events && !deleted} - - - - - - {/if} - -
- pipelineStatus(type).iconClass} - > - {#snippet noIssues()} - {#if events} - The pipeline experienced no issues in the observed period. - {/if} - {/snippet} - -
-
- + {#snippet main()} +
+ pipelineStatus(type).severity} + getBarColor={(type, h) => pipelineStatus(type).barColor(h)} + getStatusStyle={(type) => pipelineStatus(type).statusStyle} + selectedBars={selectedEventTimestamp + ? { from: selectedEventTimestamp.from, to: selectedEventTimestamp.to } + : null} + > + + {#if !events && !deleted} + + + + + + {/if} + +
+ pipelineStatus(type).iconClass} + > + {#snippet noIssues()} + {#if events} + The pipeline experienced no issues in the observed period. + {/if} + {/snippet} + +
+
+ {/snippet} {#if selectedEvent} Pipeline is running, but has not reported usage telemetry yet
{:else}
-
(openDrawer = null)} + localStorageKey="layout/drawer/pipelinePerformance" > -
-
-
-
-
Records Ingested
-
- {formatQty(global.total_input_records)} -
-
-
-
Records Processed
-
- {formatQty(global.total_processed_records)} -
-
-
-
Records Buffered
-
- {formatQty(global.buffered_input_records)} -
-
- {#snippet age()} -
- {#if global.start_time > 0} - On {formatDateTime({ ms: global.start_time * 1000 })} + {#snippet main()} +
+
+
+
+
+
Records Ingested
+
+ {formatQty(global.total_input_records)} +
+
+
+
Records Processed
+
+ {formatQty(global.total_processed_records)} +
+
+
+
Records Buffered
+
+ {formatQty(global.buffered_input_records)} +
+
+ {#snippet age()} +
+ {#if global.start_time > 0} + On {formatDateTime({ ms: global.start_time * 1000 })} + {:else} + Not deployed + {/if} +
+ {/snippet} + {#snippet updated()} +
+ {getDeploymentStatusLabel(pipeline.current.status)} since {Dayjs( + pipeline.current.deploymentStatusSince + ).format('MMM D, YYYY h:mm A')} +
+ {/snippet} + {#if isXl.current} +
+
+ Deployment age - + + {#if global.start_time > 0} + {formatElapsedTime(new Date(global.start_time * 1000))} + {:else} + N/A + {/if} +
+ {@render age()} +
+
+
+ Last status update - {formatElapsedTime( + new Date(pipeline.current.deploymentStatusSince) + )} +
+ {@render updated()} +
{:else} - Not deployed +
+ (statusTab = v)} + items={[ + { value: 'age', label: 'Age' }, + { value: 'updated', label: 'Last status update' } + ]} + class="-mt-3" + /> + {#if statusTab === 'age'} + {@render age()} + {:else if statusTab === 'updated'} + {@render updated()}{/if} +
{/if}
- {/snippet} - {#snippet updated()} -
- {getDeploymentStatusLabel(pipeline.current.status)} since {Dayjs( - pipeline.current.deploymentStatusSince - ).format('MMM D, YYYY h:mm A')} +
+
+
+
- {/snippet} - {#if isXl.current} -
-
- Deployment age - - - {#if global.start_time > 0} - {formatElapsedTime(new Date(global.start_time * 1000))} - {:else} - N/A - {/if} -
- {@render age()} +
+
-
-
- Last status update - {formatElapsedTime( - new Date(pipeline.current.deploymentStatusSince) - )} -
- {@render updated()} +
+
- {:else} -
- (statusTab = v)} - items={[ - { value: 'age', label: 'Age' }, - { value: 'updated', label: 'Last status update' } - ]} - class="-mt-3" - /> - {#if statusTab === 'age'} - {@render age()} - {:else if statusTab === 'updated'} - {@render updated()}{/if} -
- {/if} -
-
-
-
- -
-
- -
-
- +
+ (openDrawer = { kind: 'checkpoints' })} + /> +
+ {#if metrics.current.views.size || metrics.current.tables.size} +
+ +
+ {/if}
- (openDrawer = { kind: 'checkpoints' })} - /> - -
- {#if metrics.current.views.size || metrics.current.tables.size} -
- -
- {/if} -
- (openDrawer = null)}> + {/snippet} {#if openDrawer?.kind === 'connector'} `${value}%` + +/** + * Read the numeric magnitude of a {@link Percent}. + * @example percentValue('30%') // 30 + */ +export const percentValue = (value: Percent): number => parseFloat(value) diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte index ae6fc263c48..d96a8a92d21 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte @@ -264,53 +264,61 @@
-
- -
- {#each Object.entries(componentLabels) as [tag, label], i} - e.tag === tag)} - startAt={firstTimestamp(events)} - endAt={lastTimestamp(events)} - unitDurationMs={60 * 60 * 1000} - class="flex flex-col gap-2" - onBarClick={(group) => handleBarClick(tag as EventTag, group)} - legend={i === 2 ? (['healthy', 'unhealthy', 'major_issue'] as ClusterEventType[]) : []} - selectedBars={selectedEventTimestamp?.tag === tag - ? { from: selectedEventTimestamp.from, to: selectedEventTimestamp.to } - : null} - getSeverity={(type) => clusterStatus(type).severity} - getBarColor={(type, h) => clusterStatus(type).barColor(h)} - getStatusStyle={(type) => clusterStatus(type).statusStyle} - > - {/each} -
- {#if !events} - - - - - - {/if} - clusterStatus(type).iconClass} - > - {#snippet noIssues()} - {#if events} - The cluster experienced no issues in the observed period. + + {#snippet main()} +
+ +
+ {#each Object.entries(componentLabels) as [tag, label], i} + e.tag === tag)} + startAt={firstTimestamp(events)} + endAt={lastTimestamp(events)} + unitDurationMs={60 * 60 * 1000} + class="flex flex-col gap-2" + onBarClick={(group) => handleBarClick(tag as EventTag, group)} + legend={i === 2 + ? (['healthy', 'unhealthy', 'major_issue'] as ClusterEventType[]) + : []} + selectedBars={selectedEventTimestamp?.tag === tag + ? { from: selectedEventTimestamp.from, to: selectedEventTimestamp.to } + : null} + getSeverity={(type) => clusterStatus(type).severity} + getBarColor={(type, h) => clusterStatus(type).barColor(h)} + getStatusStyle={(type) => clusterStatus(type).statusStyle} + > + {/each} +
+ {#if !events} + + + + + {/if} - {/snippet} - -
- - + clusterStatus(type).iconClass} + > + {#snippet noIssues()} + {#if events} + The cluster experienced no issues in the observed period. + {/if} + {/snippet} + +
+ {/snippet} {#if selectedEvent}