Skip to content
Merged
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
5 changes: 3 additions & 2 deletions src/components/Input/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,27 @@ type IconButtonProps = {
icon: string;
className?: string;
disabled?: boolean;
loading?: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
buttonType: ButtonType;
buttonSize: SizeType;
tooltip?: string;
};

const IconButton = (
{ buttonSize, buttonType, className, disabled, icon, onClick, tooltip }: IconButtonProps,
{ buttonSize, buttonType, className, disabled, icon, loading, onClick, tooltip }: IconButtonProps,
) => (
<Button
className={cx(
'rounded-lg',
className,
buttonTypeClasses[buttonType],
buttonSizeClasses[buttonSize],
!disabled && 'cursor-pointer',
)}
onClick={onClick}
tooltip={tooltip}
disabled={disabled}
loading={loading}
>
<Icon path={icon} size={1} />
</Button>
Expand Down
9 changes: 9 additions & 0 deletions src/core/react-query/logging/helpers.ts
Original file line number Diff line number Diff line change
@@ -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}`);
43 changes: 43 additions & 0 deletions src/core/react-query/logging/mutations.ts
Original file line number Diff line number Diff line change
@@ -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<Blob, unknown, LogsSearchParamsType>({
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);
},
});
11 changes: 1 addition & 10 deletions src/pages/logs/LogSearchView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogLevelType>;
onClearFilters: () => void;
Expand Down
24 changes: 23 additions & 1 deletion src/pages/logs/LogsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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';

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';
Expand All @@ -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);
Expand All @@ -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 (
<>
<title>Logs | Shoko</title>
Expand Down Expand Up @@ -92,6 +106,14 @@ const LogsPage = () => {
onClick={clearFilters}
tooltip="Clear filters"
/>
<IconButton
icon={mdiDownload}
buttonType="secondary"
buttonSize="normal"
loading={isDownloading}
onClick={handleDownload}
tooltip="Download logs"
/>
<IconButton
icon={mdiArrowVerticalLock}
buttonType="secondary"
Expand Down