src/hooks/useLogData/index.ts (193 lines of code) (raw):
import { useCallback, useEffect, useMemo, useState } from 'react';
import { apiHttp } from '@/constants';
import { APIError, AsyncStatus, Log } from '@/types';
import { DataModel, defaultError } from '@hooks/useResource';
export type LogItem = Log | 'Loading' | 'Error' | undefined;
export type LogData = {
logs: Array<LogItem>;
preloadStatus: AsyncStatus;
status: AsyncStatus;
loadMore: (index: number) => void;
error: APIError | null;
localSearch: LocalSearchType;
};
export type LogDataSettings = {
preload: boolean;
paused: boolean;
url: string;
pagesize?: number;
};
type LogSearchResult = {
line: number;
char: [number, number];
};
export type SearchState = { active: boolean; result: LogSearchResult[]; current: number; query: string };
export type LocalSearchType = {
search: (str: string) => void;
result: SearchState;
nextResult: () => void;
};
const DEFAULT_PAGE_SIZE = 500;
const PRELOAD_POLL_INTERVALL = 20000;
const POSTLOAD_POLL_INTERVAL = 10000;
const emptyArray: LogItem[] = [];
const emptySearchResult = { active: false, result: [], current: 0, query: '' };
function isOkResult(param: DataModel<Log[]> | APIError): param is DataModel<Log[]> {
return 'data' in param;
}
/**
* Currently useLogData does not include websocket support.
*
* Log amounts might be massive so we want to load them paginated and on demand depending on user
* actions. When preload is active (task is on running status) we first load what ever we get and after
* task completes we need to fetch again.
*/
const useLogData = ({ preload, paused, url, pagesize }: LogDataSettings): LogData => {
const [status, setStatus] = useState<AsyncStatus>('NotAsked');
const [preloadStatus, setPreloadStatus] = useState<AsyncStatus>('NotAsked');
const [error, setError] = useState<APIError | null>(null);
const [postPoll, setPostPoll] = useState<boolean>(false);
// Datastore
const [logs, setLogs] = useState<LogItem[]>(emptyArray);
const PAGE_SIZE = pagesize || DEFAULT_PAGE_SIZE;
// generic log fetcher
const fetchLogs = useCallback(
(
page: number,
order: '+' | '-' = '+',
isPostPoll = false,
): Promise<{ type: 'error'; error: APIError } | { type: 'ok'; data: Log[] }> => {
const requestUrl = url;
const fullUrl = `${requestUrl}${requestUrl.indexOf('?') > -1 ? '&' : '?'}_limit=${PAGE_SIZE}${
page ? `&_page=${page}` : ''
}&_order=${order}row`;
return fetch(apiHttp(fullUrl))
.then((response) => response.json())
.then((result: DataModel<Log[]> | APIError) => {
if (isOkResult(result)) {
// Check if there was any new lines. If there wasn't, let's cancel post finish polling.
// Or if was postpoll and we didnt get any results
if (
(result.data.length > 0 && logs.length > 0 && result.data[0].row === logs.length - 1) ||
(isPostPoll && result.data.length === 0)
) {
setPostPoll(false);
}
setLogs((array) => {
const newarr = [...array];
for (const item of result.data) {
newarr[item.row] = item;
}
return newarr;
});
return { type: 'ok' as const, data: result.data };
} else {
return { type: 'error' as const, error: result };
}
})
.catch((e) => {
if (e instanceof DOMException) {
return { type: 'error', error: { ...defaultError, id: 'user-aborted' } };
}
return { type: 'error', error: defaultError };
});
},
[PAGE_SIZE, logs.length, url],
);
const fetchPreload = useCallback(() => {
setPreloadStatus('Loading');
fetchLogs(1, '-').then((result) => {
if (result.type === 'error') {
if (result.error.id === 'user-aborted') {
setPreloadStatus('NotAsked');
return;
}
setPreloadStatus('Error');
return;
}
setPreloadStatus('Ok');
});
}, [fetchLogs]);
// Fetch logs when task gets completed
useEffect(() => {
if (status === 'NotAsked' && !paused) {
setStatus('Loading');
fetchLogs(1, '-').then((result) => {
setPostPoll(true);
if (result.type === 'error') {
if (result.error.id === 'user-aborted') {
return;
}
setStatus('Error');
setError(result.error);
return;
}
setStatus('Ok');
});
}
}, [paused, url, status, fetchLogs]);
useEffect(() => {
// For preload to happen following rules have to be matched
// paused -> Run has to be on running state
// status -> This should always be NotAsked if paused is on. Check just in case
// preload -> Run has to be runnign
// preloadStatus -> We havent triggered this yet.
if (paused && status === 'NotAsked' && preload && preloadStatus === 'NotAsked') {
fetchPreload();
}
}, [paused, preload, preloadStatus, status, url, fetchPreload]);
// Poller for auto updates when task is running
useEffect(() => {
let t: number;
if (['Ok', 'Error'].includes(preloadStatus) && paused) {
t = window.setTimeout(() => {
fetchPreload();
}, PRELOAD_POLL_INTERVALL);
}
return () => {
clearTimeout(t);
};
}, [preloadStatus, paused, fetchPreload]);
// Post finish polling
// In some cases all logs might not be there after task finishes. For this, lets poll new logs every 10sec until
// there are no new lines
useEffect(() => {
let t: number;
if (status === 'Ok' && postPoll) {
t = window.setTimeout(() => {
fetchLogs(1, '-', true);
}, POSTLOAD_POLL_INTERVAL);
}
return () => {
clearTimeout(t);
};
}, [status, postPoll, logs, fetchLogs]);
// loadMore gets triggered on all scrolling events on list.
function loadMore(index: number) {
// Get page number. Add one to correct lines starting from index 0
const page = Math.ceil((index + 1) / PAGE_SIZE);
// Need to have initial page before any other request.
if ((status === 'Ok' || preloadStatus === 'Ok') && !logs[index]) {
setLogs((arr) => {
// Since we are fetching stuff as pages, find start of page where current line is
// and fill them as loading so we dont try to fetch same page again.
const startOfPage = (page - 1) * PAGE_SIZE;
const endOfPage = startOfPage + PAGE_SIZE;
for (let i = startOfPage; i < endOfPage; i++) {
arr[i] = arr[i] || 'Loading';
}
return arr;
});
fetchLogs(page, '+').then((result) => {
if (result.type === 'error') {
return;
}
});
}
}
const [searchResult, setSearchResult] = useState<SearchState>(emptySearchResult);
const search = useCallback(
(str: string) => {
if (!str) {
return setSearchResult(emptySearchResult);
}
const query = str.toLowerCase();
const results = logs
.filter(filterbySearchTerm)
.filter((line) => line.line.toLowerCase().indexOf(query) > -1)
.map((item) => {
const index = item.line.toLowerCase().indexOf(query);
return {
line: item.row,
char: [index, index + str.length] as [number, number],
};
});
setSearchResult({ active: true, result: results, current: 0, query: str });
},
[logs],
);
const nextResult = useCallback(() => {
if (searchResult.current === searchResult.result.length - 1) {
setSearchResult((cur) => ({ ...cur, current: 0 }));
} else {
setSearchResult((cur) => ({ ...cur, current: cur.current + 1 }));
}
}, [searchResult]);
// Clean up on url change
useEffect(() => {
return () => {
setStatus('NotAsked');
setPreloadStatus('NotAsked');
setLogs(emptyArray);
setError(null);
setPostPoll(false);
setSearchResult(emptySearchResult);
};
}, [url]);
const localSearch = useMemo(() => ({ search, nextResult, result: searchResult }), [nextResult, search, searchResult]);
return { logs, status, preloadStatus, error, loadMore, localSearch };
};
function filterbySearchTerm(item: LogItem): item is Log {
return typeof item === 'object';
}
export default useLogData;