diff --git a/src/components/Input/IconButton.tsx b/src/components/Input/IconButton.tsx index 971dda87d..6e6a54c96 100644 --- a/src/components/Input/IconButton.tsx +++ b/src/components/Input/IconButton.tsx @@ -11,6 +11,7 @@ type IconButtonProps = { icon: string; className?: string; disabled?: boolean; + loading?: boolean; onClick: MouseEventHandler; buttonType: ButtonType; buttonSize: SizeType; @@ -18,7 +19,7 @@ type IconButtonProps = { }; const IconButton = ( - { buttonSize, buttonType, className, disabled, icon, onClick, tooltip }: IconButtonProps, + { buttonSize, buttonType, className, disabled, icon, loading, onClick, tooltip }: IconButtonProps, ) => ( diff --git a/src/core/react-query/logging/helpers.ts b/src/core/react-query/logging/helpers.ts new file mode 100644 index 000000000..61b4e59ba --- /dev/null +++ b/src/core/react-query/logging/helpers.ts @@ -0,0 +1,9 @@ +// DSL grammar the server accepts (LogService.TryParseLogFilterDsl): a mode char first — c: contains, +// =: equals, ^: starts, $: ends, ~: fuzzy, *: regex — optionally followed by at most one ! (negate) +// and one # (case-insensitive) in either order, then ':'. ! and # are modifiers, never prefixes on +// their own (e.g. "!c:foo" or "#:foo" are server-side 400s). A bare value is shorthand for "c:" +// (case-sensitive contains), so we make it case-insensitive by default unless the user typed valid +// DSL themselves; anything else gets wrapped as a literal. +const hasDslPrefix = (value: string) => /^[c=^$~*](?:!#|#!|!|#)?:/.test(value); + +export const toServerSearch = (value: string) => (!value || hasDslPrefix(value) ? value : `c#:${value}`); diff --git a/src/core/react-query/logging/mutations.ts b/src/core/react-query/logging/mutations.ts new file mode 100644 index 000000000..a616ad6ad --- /dev/null +++ b/src/core/react-query/logging/mutations.ts @@ -0,0 +1,43 @@ +import { useMutation } from '@tanstack/react-query'; + +import { axios } from '@/core/axios'; +import { dayjs } from '@/core/util'; + +import type { LogsSearchParamsType } from '@/core/react-query/logging/types'; + +export const useLogsDownloadMutation = () => + useMutation({ + mutationFn: ({ levels, search }) => { + // Same derivation as filtersActive in LogsPage: any active filter switches + // from the current-file download to a range download with those filters. + const filtersActive = levels.size > 0 || search !== ''; + return axios.get( + filtersActive ? 'Logging/Range/Download' : 'Logging/File/Current/Download', + { + responseType: 'blob', + params: filtersActive + ? { + level: [...levels].sort().join(',') || undefined, + message: search || undefined, + } + : undefined, + }, + ); + }, + onSuccess: (blob) => { + // The shared axios instance unwraps response.data in an interceptor, so the + // Content-Disposition header (and its server-side filename) is not reachable; + // derive a local filename instead. + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `shoko-logs-${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.txt`; + // Firefox requires the anchor to be in the DOM for `download` to take effect, + // and revoking the URL immediately after click() can cancel the download in + // Firefox/Safari before it starts — defer the revoke. + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }, + }); diff --git a/src/pages/logs/LogSearchView.tsx b/src/pages/logs/LogSearchView.tsx index d5f3d6139..6198a4605 100644 --- a/src/pages/logs/LogSearchView.tsx +++ b/src/pages/logs/LogSearchView.tsx @@ -4,22 +4,13 @@ import { Icon } from '@mdi/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import Button from '@/components/Input/Button'; +import { toServerSearch } from '@/core/react-query/logging/helpers'; import { useLogsSearchQuery } from '@/core/react-query/logging/queries'; import useVirtualizerScrollRectWorkaround from '@/hooks/useVirtualizerScrollRectWorkaround'; import LogRow from '@/pages/logs/LogRow'; import type { LogLevelType } from '@/core/react-query/logging/types'; -// DSL grammar the server accepts (LogService.TryParseLogFilterDsl): a mode char first — c: contains, -// =: equals, ^: starts, $: ends, ~: fuzzy, *: regex — optionally followed by at most one ! (negate) -// and one # (case-insensitive) in either order, then ':'. ! and # are modifiers, never prefixes on -// their own (e.g. "!c:foo" or "#:foo" are server-side 400s). A bare value is shorthand for "c:" -// (case-sensitive contains), so we make it case-insensitive by default unless the user typed valid -// DSL themselves; anything else gets wrapped as a literal. -const hasDslPrefix = (value: string) => /^[c=^$~*](?:!#|#!|!|#)?:/.test(value); - -const toServerSearch = (value: string) => (!value || hasDslPrefix(value) ? value : `c#:${value}`); - type Props = { activeLevels: Set; onClearFilters: () => void; diff --git a/src/pages/logs/LogsPage.tsx b/src/pages/logs/LogsPage.tsx index 276603a78..04ab22a97 100644 --- a/src/pages/logs/LogsPage.tsx +++ b/src/pages/logs/LogsPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { mdiArrowVerticalLock, mdiFilterRemoveOutline, mdiMagnify } from '@mdi/js'; +import { mdiArrowVerticalLock, mdiDownload, mdiFilterRemoveOutline, mdiMagnify } from '@mdi/js'; import cx from 'classnames'; import { useImmer } from 'use-immer'; import { useDebounceValue } from 'usehooks-ts'; @@ -7,7 +7,10 @@ import { useDebounceValue } from 'usehooks-ts'; import Button from '@/components/Input/Button'; import IconButton from '@/components/Input/IconButton'; import Input from '@/components/Input/Input'; +import { toServerSearch } from '@/core/react-query/logging/helpers'; +import { useLogsDownloadMutation } from '@/core/react-query/logging/mutations'; import { useLogsQuery } from '@/core/react-query/logging/queries'; +import toast from '@/core/toast'; import { formatThousand } from '@/core/util'; import LogLevelChip from '@/pages/logs/LogLevelChip'; import LogLiveView from '@/pages/logs/LogLiveView'; @@ -21,6 +24,8 @@ const logLevels: LogLevelType[] = ['Trace', 'Debug', 'Information', 'Warning', ' const LogsPage = () => { const logLines = useLogsQuery().data; + const { isPending: isDownloading, mutate: downloadLogs } = useLogsDownloadMutation(); + const [scrollToBottom, setScrollToBottom] = useState(true); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebounceValue(search.trim(), 250); @@ -46,6 +51,15 @@ const LogsPage = () => { setActiveLevels(new Set()); }; + const handleDownload = () => { + downloadLogs({ + levels: activeLevels, + search: toServerSearch(debouncedSearch), + }, { + onError: () => toast.error('Failed to download logs.'), + }); + }; + return ( <> Logs | Shoko @@ -92,6 +106,14 @@ const LogsPage = () => { onClick={clearFilters} tooltip="Clear filters" /> +