feat(ui): Implement log copy

This commit is contained in:
FoxxMD 2026-07-06 18:03:38 +00:00
parent b2b2018764
commit 7151d96b59
4 changed files with 85 additions and 18 deletions

View file

@ -1,6 +1,7 @@
import { ComponentProps, useMemo } from 'react';
import { Clipboard, IconButton } from "@chakra-ui/react"
import { ComponentProps, useMemo, useCallback, useEffect, useState } from 'react';
import { Clipboard, IconButton, useClipboard } from "@chakra-ui/react"
import {safeStringify} from '../../core/StringUtils';
import { CheckIcon, CopyIcon } from './icons/ChakraIcons';
export const ChakraClip = (props: Omit<ComponentProps<typeof Clipboard.Root>, 'children' | 'value'> & {value: any}) => {
@ -23,4 +24,48 @@ export const ChakraClip = (props: Omit<ComponentProps<typeof Clipboard.Root>, 'c
</Clipboard.Trigger>
</Clipboard.Root>
)
}
const createCopyVal = (value: any) => {
if(typeof value === 'string') {
return value;
}
return safeStringify(value);
}
/**
* Copies value from onCopy only when clicked
*/
export const ChakraClipDynamic = (props: Omit<ComponentProps<typeof Clipboard.Root>, 'children' | 'value'> & {onCopy: () => any}) => {
const {
onCopy,
...rest
} = props;
// idk what's going on here exactly but just using clip.setValue does not set clipboard, it requires two clicks to get actual value
// so instead using some useState abuse to re-render component and wait to copy until everything matches
// probably related to this https://github.com/chakra-ui/chakra-ui/issues/6759
const [clipVal, setClipVal] = useState<string | undefined>();
const clip = useClipboard({timeout: 1000});
useEffect(() => {
if(clipVal !== undefined && clip.value === clipVal) {
//console.log('run copy');
clip.copy();
setClipVal(undefined);
}
},[clip.value, clipVal, setClipVal]);
const invokeCopy = useCallback(() => {
const val = createCopyVal(onCopy());
//console.log(val);
setClipVal(val);
clip.setValue(val);
},[onCopy, clip.setValue]);
return (
<IconButton variant="surface" size="xs" onClick={invokeCopy}>
{!clip.copied ? <CopyIcon/> : <CheckIcon/>}
</IconButton>
)
}

View file

@ -1,4 +1,4 @@
import React, {PropsWithChildren, useState, useEffect} from 'react';
import React, {PropsWithChildren, useState, useEffect, useRef, useCallback} from 'react';
import * as AnsiImport from "ansi-to-react";
import { Text, Box, SegmentGroup, Separator, HStack, Stack, Span } from '@chakra-ui/react';
import {FixedSizeList} from "fixed-size-list";
@ -6,8 +6,9 @@ import {useSSE} from "@flamefrontend/sse-runtime-react";
import { useQueryClient, QueryFunctionContext, useQuery, useMutation } from '@tanstack/react-query'
import { LogOutputConfig } from '../../core/Atomic';
import ky from 'ky';
import { baseUrl } from '../utils';
import { LogLevel } from '@foxxmd/logging';
import { tanQueries } from '../queries';
import { ChakraClip, ChakraClipDynamic } from './ChakraClipboard';
// @ts-expect-error Ansi export is built incorrectly
const Ansi = AnsiImport.default.default as typeof AnsiImport.default;
@ -35,8 +36,8 @@ const createFixedList = (size, initialList: MinLogInfo[] = []): FixedSizeList<Mi
let list = createFixedList(50);
export const Logs = (props: {logs: Readonly<LogLineProps[]>}) => {
return <Box fontFamily="source-code-pro, Menlo, Monaco, Consolas,'Courier New',monospace;">
export const Logs = (props: {logs: Readonly<LogLineProps[]>, ref?: React.Ref<HTMLDivElement>}) => {
return <Box ref={props.ref} fontFamily="source-code-pro, Menlo, Monaco, Consolas,'Courier New',monospace;">
{props.logs.map(x => <LogLine message={x.message}/>)}
</Box>
}
@ -55,9 +56,9 @@ export const LogsFetchable = (props: {settings?: LogOutputConfig, streamable?: b
const [logLimit, setLogLimit] = useState(limit);
const [logList, setLogList] = useState<MinLogInfo[]>([]);
const { isPending, isError, data, error } = useQuery({
queryKey: ['logs', { level: logLevel, limit: logLimit }],
queryFn: queryFn,
...tanQueries.logs.list(logLevel, logLimit),
staleTime: Infinity
});
@ -123,14 +124,20 @@ export const LogsFetchable = (props: {settings?: LogOutputConfig, streamable?: b
}
}, [data, limit,setLogList]);
const logRef = useRef<HTMLDivElement>(null);
const getLogCopyText = useCallback(() =>{
const content = logRef.current.innerText;
return content.replaceAll(/\n\[/g, '[');
},[logRef]);
return (<Stack>
<HStack gap="5"><Span>Level: {levelGroup}</Span><Separator orientation="vertical" height="4"/><Span>Limit: {limitGroup}</Span></HStack>
<Logs logs={logList}/>
<HStack gap="5">
<Span>Level: {levelGroup}</Span>
<Separator orientation="vertical" height="4"/><Span marginEnd="auto">Limit: {limitGroup}</Span>
<ChakraClipDynamic onCopy={getLogCopyText}/>
</HStack>
<Logs ref={logRef} logs={logList}/>
</Stack>);
}
type LogsQueryKey = ['logs', {level: string, limit: number}];
const queryFn = async (context: QueryFunctionContext<LogsQueryKey>) => {
return await ky.get(`logs`, { baseUrl: baseUrl }).json() as {data: {line: string, time: number, levelLabel: string, level: number}[]};
}

View file

@ -20,7 +20,8 @@ import {
LuEye,
LuEyeClosed,
LuCalendar,
LuRefreshCw
LuRefreshCw,
LuCopy
} from "react-icons/lu"
import { VscDebugRestart } from 'react-icons/vsc';
import { MdOutlineFiberNew } from "react-icons/md";
@ -159,4 +160,7 @@ export const RefreshButton = makeIconButton(RefreshIcon);
export const InsertedIcon = makeChakraIcon(MdOutlineFiberNew);
export const UpdatedIcon = makeChakraIcon(LuCircleArrowUp);
export const UpdatedIcon = makeChakraIcon(LuCircleArrowUp);
export const CopyIcon = makeChakraIcon(LuCopy);
export const CopyIconButton = makeIconButton(LuCopy);

View file

@ -59,9 +59,20 @@ const players = createQueryKeys('players', {
queryKey: ['components', componentId, 'play', platformId],
queryFn: (ctx) => ky.get(`components/${componentId}/players/${platformId}`, { baseUrl }).json<SourcePlayerJson>()
})
});
const logs = createQueryKeys('logs', {
list: (level: string, limit: number) => ({
queryKey: ['logs', {level, limit}],
queryFn: (ctx) => {
return ky.get(`logs`, {
baseUrl: baseUrl
}).json<{data: {line: string, time: number, levelLabel: string, level: number}[]}>();
}
})
})
export const tanQueries = mergeQueryKeys(components, activities, players);
export const tanQueries = mergeQueryKeys(components, activities, players, logs);
export const useQueryState = (queryKey: Readonly<unknown[]>) => {
const queryClient = useQueryClient()