mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Console 1889 remove react virtuoso in favor of tanstackvirtual (#7744)
This commit is contained in:
parent
ade45f5a70
commit
d6011f9c2e
5 changed files with 147 additions and 43 deletions
|
|
@ -133,7 +133,6 @@
|
|||
"react-textarea-autosize": "8.5.9",
|
||||
"react-toastify": "10.0.6",
|
||||
"react-virtualized-auto-sizer": "1.0.25",
|
||||
"react-virtuoso": "4.12.3",
|
||||
"recharts": "2.15.1",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"snarkdown": "2.0.0",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useClient, useMutation, useQuery } from 'urql';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -30,6 +28,7 @@ import { Separator } from '@/components/ui/separator';
|
|||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { VirtualLogList } from '@/components/ui/virtual-log-list';
|
||||
import { Tag } from '@/components/v2';
|
||||
import { env } from '@/env/frontend';
|
||||
import { DocumentType, FragmentType, graphql, useFragment } from '@/gql';
|
||||
|
|
@ -1414,13 +1413,6 @@ function DebugOIDCIntegrationModal(props: {
|
|||
const [isSubscribing, setIsSubscribing] = useResetState(true, [props.isOpen]);
|
||||
|
||||
const [logs, setLogs] = useResetState<Array<OIDCLogEventType>>([], [props.isOpen]);
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollToIndex({
|
||||
index: logs.length - 1,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [logs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubscribing && props.oidcIntegrationId && props.isOpen) {
|
||||
|
|
@ -1467,23 +1459,7 @@ function DebugOIDCIntegrationModal(props: {
|
|||
Here you can listen to the live logs for debugging your OIDC integration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Virtuoso
|
||||
ref={ref}
|
||||
className="h-[300px]"
|
||||
initialTopMostItemIndex={logs.length - 1}
|
||||
followOutput
|
||||
data={logs}
|
||||
itemContent={(_, logRow) => {
|
||||
return (
|
||||
<div className="flex px-2 pb-1 font-mono text-xs">
|
||||
<time dateTime={logRow.timestamp} className="pr-4">
|
||||
{format(logRow.timestamp, 'HH:mm:ss')}
|
||||
</time>
|
||||
{logRow.message}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<VirtualLogList logs={logs} className="h-[300px]" />
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={props.close} tabIndex={0} variant="destructive">
|
||||
Close
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import { VirtualLogList, type LogEntry } from './virtual-log-list';
|
||||
|
||||
export default {
|
||||
title: 'UI / VirtualLogList',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
const sampleMessages = [
|
||||
'OIDC discovery document fetched successfully',
|
||||
'Token validation started for client_id=hive-console',
|
||||
'ID token signature verified with RS256',
|
||||
'Claims extracted: sub=user-123, email=user@example.com',
|
||||
'User session created, redirecting to callback URL',
|
||||
'Authorization code exchange completed',
|
||||
'Refresh token rotation initiated',
|
||||
'Access token issued, expires_in=3600',
|
||||
'Userinfo endpoint called successfully',
|
||||
'Logout request received, clearing session',
|
||||
];
|
||||
|
||||
function generateLog(index: number): LogEntry {
|
||||
const now = new Date();
|
||||
now.setSeconds(now.getSeconds() - (100 - index));
|
||||
return {
|
||||
timestamp: now.toISOString(),
|
||||
message: sampleMessages[index % sampleMessages.length],
|
||||
};
|
||||
}
|
||||
|
||||
const staticLogs: LogEntry[] = Array.from({ length: 100 }, (_, i) => generateLog(i));
|
||||
|
||||
export const Default: Story = () => (
|
||||
<div className="p-8">
|
||||
<p className="text-neutral-11 mb-2 text-sm">100 pre-loaded log entries (scrollable):</p>
|
||||
<VirtualLogList logs={staticLogs} className="h-[300px]" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const StreamingLogs: Story = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>(() =>
|
||||
Array.from({ length: 5 }, (_, i) => generateLog(i)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let index = 5;
|
||||
const interval = setInterval(() => {
|
||||
setLogs(prev => [...prev, generateLog(index++)]);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<p className="text-neutral-11 mb-2 text-sm">
|
||||
Streaming logs (new entry every 500ms, auto-scrolls to bottom):
|
||||
</p>
|
||||
<p className="text-neutral-10 mb-4 text-xs">{logs.length} entries</p>
|
||||
<VirtualLogList logs={logs} className="h-[300px]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Empty: Story = () => (
|
||||
<div className="p-8">
|
||||
<p className="text-neutral-11 mb-2 text-sm">Empty log list:</p>
|
||||
<VirtualLogList logs={[]} className="h-[300px]" />
|
||||
</div>
|
||||
);
|
||||
74
packages/web/app/src/components/ui/virtual-log-list.tsx
Normal file
74
packages/web/app/src/components/ui/virtual-log-list.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
export type LogEntry = {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const ITEM_HEIGHT = 24;
|
||||
|
||||
export function VirtualLogList(props: { logs: LogEntry[]; className?: string }) {
|
||||
const { logs } = props;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const atBottomRef = useRef(true);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollEl;
|
||||
atBottomRef.current = scrollHeight - scrollTop - clientHeight < ITEM_HEIGHT;
|
||||
};
|
||||
|
||||
scrollEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
|
||||
return () => scrollEl.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (atBottomRef.current && logs.length > 0) {
|
||||
virtualizer.scrollToIndex(logs.length - 1, { behavior: 'smooth', align: 'end' });
|
||||
}
|
||||
}, [logs.length, virtualizer]);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={props.className} style={{ overflow: 'auto' }}>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(virtualItem => {
|
||||
const logRow = logs[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex px-2 pb-1 font-mono text-xs">
|
||||
<time dateTime={logRow.timestamp} className="pr-4">
|
||||
{format(logRow.timestamp, 'HH:mm:ss')}
|
||||
</time>
|
||||
{logRow.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2061,9 +2061,6 @@ importers:
|
|||
react-virtualized-auto-sizer:
|
||||
specifier: 1.0.25
|
||||
version: 1.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-virtuoso:
|
||||
specifier: 4.12.3
|
||||
version: 4.12.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
recharts:
|
||||
specifier: 2.15.1
|
||||
version: 2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -16766,13 +16763,6 @@ packages:
|
|||
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-virtuoso@4.12.3:
|
||||
resolution: {integrity: sha512-6X1p/sU7hecmjDZMAwN+r3go9EVjofKhwkUbVlL8lXhBZecPv9XVCkZ/kBPYOr0Mv0Vl5+Ziwgexg9Kh7+NNXQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16 || >=17 || >= 18'
|
||||
react-dom: '>=16 || >=17 || >= 18'
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -40058,11 +40048,6 @@ snapshots:
|
|||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-virtuoso@4.12.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue