diff --git a/Dockerfile.demo b/Dockerfile.demo index 49a0909afa9b6bbcfdafd74162a44775588f82bb..e1ac818fa935accc29e57fa6ba23486b2d21bcec 100644 --- a/Dockerfile.demo +++ b/Dockerfile.demo @@ -11,7 +11,6 @@ RUN ["pip", "install", "--disable-pip-version-check", "--find-links=dist", "visu WORKDIR /home/visualdl/frontend ENV SCOPE server ENV PUBLIC_PATH /paddle/visualdl/demo -ENV API_URL /paddle/visualdl/demo/api RUN ["./scripts/install.sh"] RUN ["./scripts/build.sh"] @@ -21,7 +20,6 @@ WORKDIR /home/visualdl COPY --from=builder /home/visualdl/frontend/ ./ ENV NODE_ENV production ENV PUBLIC_PATH /paddle/visualdl/demo -ENV API_URL /paddle/visualdl/demo/api ENV PING_URL /ping ENV DEMO true ENV HOST 0.0.0.0 diff --git a/frontend/packages/core/package.json b/frontend/packages/core/package.json index a429daac506639450989aea612295cc576d5c6e8..8a9c48a6c605ad15bf2ae0b88dd80f0d5fba72c4 100644 --- a/frontend/packages/core/package.json +++ b/frontend/packages/core/package.json @@ -61,7 +61,7 @@ "react-router-dom": "5.2.0", "react-spinners": "0.9.0", "react-toastify": "6.0.8", - "styled-components": "5.1.1", + "styled-components": "5.2.0", "swr": "0.3.0", "tippy.js": "6.2.6" }, diff --git a/frontend/packages/core/public/locales/en/errors.json b/frontend/packages/core/public/locales/en/errors.json index 74d7b9669d0f399957097ad08d2fca20c2679600..9c60fc90d502061a642ec2785a9044035807ac5b 100644 --- a/frontend/packages/core/public/locales/en/errors.json +++ b/frontend/packages/core/public/locales/en/errors.json @@ -7,7 +7,24 @@ "description": "Possible reasons are:", "title": "No visualized data." }, - "error-with-status": "A {{statusCode}} error occurred on server", - "error-without-status": "An error occurred on the server", - "page-not-found": "Page Not Found" + "error": "Error occurred", + "network-error": "Network Error", + "page-not-found": "Page Not Found", + "parse-error": "Parse Error", + "response-error": { + "400": "Bad Request", + "401": "Unauthorized", + "403": "Permission Denied", + "404": "Interface Does Not Exist", + "405": "Method Not Allowed", + "408": "Request Timeout", + "413": "Request Entity Too Large", + "414": "Request-URI Too Long", + "500": "Internal Server Error", + "501": "Not Implemented", + "502": "Bad Gateway", + "503": "Service Unavailable", + "504": "Gateway Timeout", + "unknown": "Server Error" + } } diff --git a/frontend/packages/core/public/locales/zh/errors.json b/frontend/packages/core/public/locales/zh/errors.json index dc4f4553289c411f38b2b628b0abe1ca87d2d7a0..b7e888236d7bd892a0baced7d11ff22a04b5ea08 100644 --- a/frontend/packages/core/public/locales/zh/errors.json +++ b/frontend/packages/core/public/locales/zh/errors.json @@ -7,7 +7,24 @@ "description": "有以下几种可能原因,请您参考相应解决方案:", "title": "无可视化结果展示" }, - "error-with-status": "服务器发生了一个 {{statusCode}} 错误", - "error-without-status": "服务器发生了一个错误", - "page-not-found": "页面不存在" + "error": "发生错误", + "network-error": "网络错误", + "page-not-found": "页面不存在", + "parse-error": "解析失败", + "response-error": { + "400": "请求错误", + "401": "未授权", + "403": "没有权限", + "404": "接口不存在", + "405": "方法不允许", + "408": "请求超时", + "413": "请求体过大", + "414": "请求地址过长", + "500": "服务器内部错误", + "501": "服务器未实现", + "502": "服务器网关错误", + "503": "服务器不可用", + "504": "服务器网关超时", + "unknown": "服务器错误" + } } diff --git a/frontend/packages/core/src/App.tsx b/frontend/packages/core/src/App.tsx index 8c1dc84702925f3fa8a28719d06c3468171fdf73..5b54584f091a819183ac08ac70992214279f8257 100644 --- a/frontend/packages/core/src/App.tsx +++ b/frontend/packages/core/src/App.tsx @@ -3,10 +3,13 @@ import {Redirect, Route, BrowserRouter as Router, Switch, useLocation} from 'rea import {headerHeight, position, size} from '~/utils/style'; import BodyLoading from '~/components/BodyLoading'; +import ErrorBoundary from '~/components/ErrorBoundary'; +import ErrorPage from '~/pages/error'; import {Helmet} from 'react-helmet'; import NProgress from 'nprogress'; import Navbar from '~/components/Navbar'; import {SWRConfig} from 'swr'; +import {ToastContainer} from 'react-toastify'; import {fetcher} from '~/utils/fetch'; import init from '@visualdl/wasm'; import routes from '~/routes'; @@ -56,7 +59,7 @@ const Telemetry: FunctionComponent = () => { }; const App: FunctionComponent = () => { - const {i18n} = useTranslation(); + const {t, i18n} = useTranslation('errors'); const dir = useMemo(() => (i18n.language ? i18n.dir(i18n.language) : ''), [i18n]); @@ -91,17 +94,23 @@ const App: FunctionComponent = () => {
- }> - - - {routers.map(route => ( - - ))} - - + }> + }> + + + {routers.map(route => ( + + ))} + + + + + + )} + ); diff --git a/frontend/packages/core/src/components/Audio.tsx b/frontend/packages/core/src/components/Audio.tsx index fc91d084f16f7ff57905c954fbf1ff4816cc6537..d3d4e7946b451193921fd7a9a89ccbb6f5bb9669 100644 --- a/frontend/packages/core/src/components/Audio.tsx +++ b/frontend/packages/core/src/components/Audio.tsx @@ -1,4 +1,3 @@ -import {BlobResponse, blobFetcher} from '~/utils/fetch'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import { WithStyled, @@ -13,12 +12,14 @@ import { } from '~/utils/style'; import {AudioPlayer} from '~/utils/audio'; +import type {BlobResponse} from '~/utils/fetch'; import Icon from '~/components/Icon'; import PuffLoader from 'react-spinners/PuffLoader'; import RangeSlider from '~/components/RangeSlider'; import Slider from 'react-rangeslider'; import SyncLoader from 'react-spinners/SyncLoader'; import Tippy from '@tippyjs/react'; +import {fetcher} from '~/utils/fetch'; import mime from 'mime-types'; import moment from 'moment'; import {saveAs} from 'file-saver'; @@ -147,7 +148,7 @@ const Audio = React.forwardRef( ({audioContext, src, cache, onLoading, onLoad, className}, ref) => { const {t} = useTranslation('common'); - const {data, error, loading} = useRequest(src ?? null, blobFetcher, { + const {data, error, loading} = useRequest(src ?? null, fetcher, { dedupingInterval: cache ?? 2000 }); diff --git a/frontend/packages/core/src/components/Error.tsx b/frontend/packages/core/src/components/Error.tsx index adca7e90cec13397cc03066b43a716a36c989881..c4ee201bce67570bebf80bac97a503cf2fcdf29e 100644 --- a/frontend/packages/core/src/components/Error.tsx +++ b/frontend/packages/core/src/components/Error.tsx @@ -46,6 +46,29 @@ const Wrapper = styled.div` const reload = () => window.location.reload(); +const ReadmeMap: Record = { + zh: 'https://github.com/PaddlePaddle/VisualDL/blob/develop/README.md', + en: 'https://github.com/PaddlePaddle/VisualDL/blob/develop/README-en.md' +}; + +const UserGuideMap: Record = { + zh: 'https://github.com/PaddlePaddle/VisualDL/blob/develop/docs/components/README.md', + en: 'https://github.com/PaddlePaddle/VisualDL/blob/develop/docs/components/UserGuide-en.md' +}; + +const I18nLink: FunctionComponent<{map: Record}> = ({map, children}) => { + const {i18n} = useTranslation(); + return ( + + {children} + + ); +}; + const Error: FunctionComponent = ({className, children}) => { const {t} = useTranslation('errors'); @@ -61,22 +84,14 @@ const Error: FunctionComponent = ({className, children}) => {
  • Log files are not generated. Please refer to  - - README - + README  to create log files.
  • Log files are generated but data is not written yet. Please refer to  - - VisualDL User Guide - + VisualDL User Guide  to write visualized data.
  • diff --git a/frontend/packages/core/src/components/ErrorBoundary.tsx b/frontend/packages/core/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1ca900fc3cfbfab21a8b1df6205c7060943965aa --- /dev/null +++ b/frontend/packages/core/src/components/ErrorBoundary.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component<{fallback: React.ReactNode}, {hasError: boolean; error: Error | null}> { + state = { + hasError: false, + error: null + }; + + static getDerivedStateFromError(error: Error) { + return { + hasError: true, + error + }; + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/packages/core/src/components/Image.tsx b/frontend/packages/core/src/components/Image.tsx index d775aa69054c2674be4add060d16d52210f9659c..b78cb35accdefcf2c7ca8c25a32286ad0a506bc8 100644 --- a/frontend/packages/core/src/components/Image.tsx +++ b/frontend/packages/core/src/components/Image.tsx @@ -1,8 +1,9 @@ -import {BlobResponse, blobFetcher} from '~/utils/fetch'; import React, {useImperativeHandle, useLayoutEffect, useState} from 'react'; import {WithStyled, primaryColor} from '~/utils/style'; +import type {BlobResponse} from '~/utils/fetch'; import GridLoader from 'react-spinners/GridLoader'; +import {fetcher} from '~/utils/fetch'; import mime from 'mime-types'; import {saveAs} from 'file-saver'; import useRequest from '~/hooks/useRequest'; @@ -21,7 +22,7 @@ const Image = React.forwardRef(({src, cache, const {t} = useTranslation('common'); const [url, setUrl] = useState(''); - const {data, error, loading} = useRequest(src ?? null, blobFetcher, { + const {data, error, loading} = useRequest(src ?? null, fetcher, { dedupingInterval: cache ?? 2000 }); diff --git a/frontend/packages/core/src/components/Navbar.tsx b/frontend/packages/core/src/components/Navbar.tsx index caaaa9bb33424438d0a18847fde41f72b54fa5f3..27a5af91ceb7373f05276ff5a1972e40a1d15e4e 100644 --- a/frontend/packages/core/src/components/Navbar.tsx +++ b/frontend/packages/core/src/components/Navbar.tsx @@ -196,7 +196,7 @@ const Navbar: FunctionComponent = () => { const currentPath = useMemo(() => pathname.replace(PUBLIC_PATH, ''), [pathname]); - const navItems = useNavItems(); + const [navItems] = useNavItems(); const [items, setItems] = useState([]); useEffect(() => { setItems(oldItems => diff --git a/frontend/packages/core/src/hooks/useLocalStorage.ts b/frontend/packages/core/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000000000000000000000000000000000000..676e810445d660a5002388da104ef819bef84503 --- /dev/null +++ b/frontend/packages/core/src/hooks/useLocalStorage.ts @@ -0,0 +1,10 @@ +import {useCallback, useMemo} from 'react'; + +const useLocalStorage = (key: string) => { + const value = useMemo(() => window.localStorage.getItem(key), [key]); + const setter = useCallback((value: string) => window.localStorage.setItem(key, value), [key]); + const remover = useCallback(() => window.localStorage.removeItem(key), [key]); + return [value, setter, remover] as const; +}; + +export default useLocalStorage; diff --git a/frontend/packages/core/src/hooks/useNavItems.ts b/frontend/packages/core/src/hooks/useNavItems.ts index c05ee98fab7be0479034e48081c95978fc6259e2..989c922d76df2b0de4c5f207d26fc5d4a3b00dc9 100644 --- a/frontend/packages/core/src/hooks/useNavItems.ts +++ b/frontend/packages/core/src/hooks/useNavItems.ts @@ -18,7 +18,7 @@ export const navMap = { const useNavItems = () => { const [components, setComponents] = useState([]); - const {data, mutate} = useRequest<(keyof typeof navMap)[]>('/components', fetcher, { + const {data, loading, error, mutate} = useRequest<(keyof typeof navMap)[]>('/components', fetcher, { refreshInterval: components.length ? 61 * 1000 : 15 * 1000, dedupingInterval: 14 * 1000, errorRetryInterval: 15 * 1000, @@ -59,9 +59,9 @@ const useNavItems = () => { useEffect(() => { setComponents(filterPages(routes)); - }, [data, filterPages]); + }, [filterPages]); - return components; + return [components, loading, error] as const; }; export default useNavItems; diff --git a/frontend/packages/core/src/hooks/useQuery.ts b/frontend/packages/core/src/hooks/useQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..f94c349f82db1aa495f1c2251bd4bd6864ae6d01 --- /dev/null +++ b/frontend/packages/core/src/hooks/useQuery.ts @@ -0,0 +1,12 @@ +import type {ParseOptions} from 'query-string'; +import queryString from 'query-string'; +import {useLocation} from 'react-router-dom'; +import {useMemo} from 'react'; + +const useQuery = (options?: ParseOptions) => { + const location = useLocation(); + const query = useMemo(() => queryString.parse(location.search, options), [location.search, options]); + return query; +}; + +export default useQuery; diff --git a/frontend/packages/core/src/hooks/useRequest.ts b/frontend/packages/core/src/hooks/useRequest.ts index 96b2f3ac551784f330f785e90df7f069f0f654c5..9922969a6e644f083d07c6bad571e7d3cc3b2bf0 100644 --- a/frontend/packages/core/src/hooks/useRequest.ts +++ b/frontend/packages/core/src/hooks/useRequest.ts @@ -1,43 +1,55 @@ +import type {ConfigInterface, keyInterface, responseInterface} from 'swr'; import {useEffect, useMemo} from 'react'; -import useSWR, {ConfigInterface, keyInterface, responseInterface} from 'swr'; import ee from '~/utils/event'; import type {fetcherFn} from 'swr/dist/types'; +import {toast} from 'react-toastify'; +import useSWR from 'swr'; type Response = responseInterface & { loading: boolean; }; -function useRequest(key: keyInterface): Response; -function useRequest(key: keyInterface, fetcher?: fetcherFn): Response; -function useRequest( +function useRequest(key: keyInterface): Response; +function useRequest(key: keyInterface, fetcher?: fetcherFn): Response; +function useRequest( key: keyInterface, fetcher?: fetcherFn, config?: ConfigInterface> ): Response; -function useRequest( +function useRequest( key: keyInterface, fetcher?: fetcherFn, config?: ConfigInterface> ): Response { const {data, error, ...other} = useSWR(key, fetcher, config); const loading = useMemo(() => !!key && !data && !error, [key, data, error]); + + useEffect(() => { + if (error) { + toast(error.message, { + position: toast.POSITION.TOP_CENTER, + type: toast.TYPE.ERROR + }); + } + }, [error]); + return {data, error, loading, ...other}; } -function useRunningRequest(key: keyInterface, running: boolean): Response; -function useRunningRequest( +function useRunningRequest(key: keyInterface, running: boolean): Response; +function useRunningRequest( key: keyInterface, running: boolean, fetcher?: fetcherFn ): Response; -function useRunningRequest( +function useRunningRequest( key: keyInterface, running: boolean, fetcher?: fetcherFn, config?: Omit>, 'dedupingInterval' | 'errorRetryInterval'> ): Response; -function useRunningRequest( +function useRunningRequest( key: keyInterface, running: boolean, fetcher?: fetcherFn, diff --git a/frontend/packages/core/src/hooks/useTagFilter.ts b/frontend/packages/core/src/hooks/useTagFilter.ts index 90f4f3f13599e6c47efd2bae30119df7f53138ef..ebdcf859980566eb58aeef73110e6156e3fdd46a 100644 --- a/frontend/packages/core/src/hooks/useTagFilter.ts +++ b/frontend/packages/core/src/hooks/useTagFilter.ts @@ -4,10 +4,9 @@ import {useCallback, useEffect, useMemo, useReducer} from 'react'; import groupBy from 'lodash/groupBy'; import intersectionBy from 'lodash/intersectionBy'; -import queryString from 'query-string'; import uniq from 'lodash/uniq'; import useGlobalState from '~/hooks/useGlobalState'; -import {useLocation} from 'react-router-dom'; +import useQuery from '~/hooks/useQuery'; import {useRunningRequest} from '~/hooks/useRequest'; type Tags = Record; @@ -148,8 +147,7 @@ const reducer = (state: State, action: Action): State => { // TODO: refactor to improve performance const useTagFilter = (type: string, running: boolean) => { - const location = useLocation(); - const query = useMemo(() => queryString.parse(location.search), [location.search]); + const query = useQuery(); const {data, loading, error} = useRunningRequest(`/${type}/tags`, running); diff --git a/frontend/packages/core/src/pages/error.tsx b/frontend/packages/core/src/pages/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6658c23a37918a25a2dda72dd6663a66941a9538 --- /dev/null +++ b/frontend/packages/core/src/pages/error.tsx @@ -0,0 +1,29 @@ +import React, {FunctionComponent} from 'react'; + +import Content from '~/components/Content'; +import ErrorComponent from '~/components/Error'; +import {useTranslation} from 'react-i18next'; + +type ErrorProps = { + title?: string; + desc?: string; +}; + +const Error: FunctionComponent = ({title, desc, children}) => { + const {t} = useTranslation('errors'); + + return ( + + + {children || ( + <> +

    {title ?? t('errors:error')}

    +

    {desc}

    + + )} +
    +
    + ); +}; + +export default Error; diff --git a/frontend/packages/core/src/pages/graph.tsx b/frontend/packages/core/src/pages/graph.tsx index 46da337560b8058970bb1ed5452f8ca4db298234..6982f8b7a7114e9d6b356701534b7a63a1525d30 100644 --- a/frontend/packages/core/src/pages/graph.tsx +++ b/frontend/packages/core/src/pages/graph.tsx @@ -1,11 +1,11 @@ import Aside, {AsideSection} from '~/components/Aside'; -import {BlobResponse, blobFetcher} from '~/utils/fetch'; import type {Documentation, OpenedResult, Properties, SearchItem, SearchResult} from '~/resource/graph/types'; import GraphComponent, {GraphRef} from '~/components/GraphPage/Graph'; import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import Select, {SelectProps} from '~/components/Select'; import {primaryColor, rem, size} from '~/utils/style'; +import type {BlobResponse} from '~/utils/fetch'; import Button from '~/components/Button'; import Checkbox from '~/components/Checkbox'; import Content from '~/components/Content'; @@ -93,7 +93,7 @@ const Graph: FunctionComponent = () => { [globalDispatch] ); - const {data, loading} = useRequest(files ? null : '/graph/graph', blobFetcher); + const {data, loading} = useRequest(files ? null : '/graph/graph'); useEffect(() => { if (data?.data.size) { diff --git a/frontend/packages/core/src/pages/high-dimensional.tsx b/frontend/packages/core/src/pages/high-dimensional.tsx index a3b225be4d5650fbb309c5f24d3f18621dafa55b..9370e3d9f77c51285a4c3a375da9df7cd090d8bc 100644 --- a/frontend/packages/core/src/pages/high-dimensional.tsx +++ b/frontend/packages/core/src/pages/high-dimensional.tsx @@ -16,9 +16,8 @@ import RunningToggle from '~/components/RunningToggle'; import SearchInput from '~/components/SearchInput'; import type {TagsData} from '~/types'; import Title from '~/components/Title'; -import queryString from 'query-string'; import styled from 'styled-components'; -import {useLocation} from 'react-router-dom'; +import useQuery from '~/hooks/useQuery'; import {useRunningRequest} from '~/hooks/useRequest'; import useSearchValue from '~/hooks/useSearchValue'; import {useTranslation} from 'react-i18next'; @@ -66,8 +65,7 @@ const HighDimensional: FunctionComponent = () => { }, [data]); const labelList = useMemo(() => list.map(item => item.label), [list]); - const location = useLocation(); - const query = useMemo(() => queryString.parse(location.search), [location]); + const query = useQuery(); const selectedLabel = useMemo(() => { const run = Array.isArray(query.run) ? query.run[0] : query.run; return (run && list.find(item => item.run === run)?.label) ?? list[0]?.label; diff --git a/frontend/packages/core/src/pages/index.tsx b/frontend/packages/core/src/pages/index.tsx index 7ba1ac41a9e79999758127804b98ea420285aaef..846fc04135beea4a683351f479b2fdbd949d1f57 100644 --- a/frontend/packages/core/src/pages/index.tsx +++ b/frontend/packages/core/src/pages/index.tsx @@ -1,45 +1,56 @@ import React, {FunctionComponent, useEffect} from 'react'; import {headerHeight, primaryColor, rem, size} from '~/utils/style'; +import {useHistory, useLocation} from 'react-router-dom'; +import Error from '~/components/Error'; import HashLoader from 'react-spinners/HashLoader'; import styled from 'styled-components'; -import {useHistory} from 'react-router-dom'; import useNavItems from '~/hooks/useNavItems'; import {useTranslation} from 'react-i18next'; -const Loading = styled.div` +const CenterWrapper = styled.div` ${size(`calc(100vh - ${headerHeight})`, '100vw')} display: flex; flex-direction: column; justify-content: center; align-items: center; overscroll-behavior: none; - cursor: progress; +`; + +const Loading = styled.div` font-size: ${rem(16)}; line-height: ${rem(60)}; `; const IndexPage: FunctionComponent = () => { - const navItems = useNavItems(); + const [navItems, loading] = useNavItems(); const history = useHistory(); const {t} = useTranslation('common'); + const location = useLocation(); + useEffect(() => { if (navItems.length) { if (navItems[0].path) { - history.replace(navItems[0].path); + history.replace(navItems[0].path + location.search); } else if (navItems[0].children?.length && navItems[0].children[0].path) { - history.replace(navItems[0].children[0].path); + history.replace(navItems[0].children[0].path + location.search); } } - }, [navItems, history]); + }, [navItems, history, location.search]); return ( - - - {t('common:loading')} - + + {loading || navItems.length ? ( + + + {t('common:loading')} + + ) : ( + + )} + ); }; diff --git a/frontend/packages/core/src/pages/scalar.tsx b/frontend/packages/core/src/pages/scalar.tsx index 9e770100e3b1a750f7a32d12b3e1f36afe1447f6..04076ec28b5d117ef400ffc8ab0b46ec5aa5acbf 100644 --- a/frontend/packages/core/src/pages/scalar.tsx +++ b/frontend/packages/core/src/pages/scalar.tsx @@ -1,6 +1,6 @@ import ChartPage, {WithChart} from '~/components/ChartPage'; -import React, {FunctionComponent, useCallback, useMemo, useState} from 'react'; -import {SortingMethod, XAxis, sortingMethod as toolTipSortingValues} from '~/resource/scalar'; +import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react'; +import {SortingMethod, XAxis, parseSmoothing, sortingMethod as toolTipSortingValues} from '~/resource/scalar'; import {AsideSection} from '~/components/Aside'; import Checkbox from '~/components/Checkbox'; @@ -16,6 +16,8 @@ import TimeModeSelect from '~/components/TimeModeSelect'; import Title from '~/components/Title'; import {rem} from '~/utils/style'; import styled from 'styled-components'; +import useLocalStorage from '~/hooks/useLocalStorage'; +import useQuery from '~/hooks/useQuery'; import useTagFilter from '~/hooks/useTagFilter'; import {useTranslation} from 'react-i18next'; @@ -33,12 +35,21 @@ const TooltipSortingDiv = styled.div` const Scalar: FunctionComponent = () => { const {t} = useTranslation(['scalar', 'common']); + const query = useQuery(); const [running, setRunning] = useState(true); const {runs, tags, selectedRuns, onChangeRuns, loading} = useTagFilter('scalar', running); - const [smoothing, setSmoothing] = useState(0.6); + const [smoothingFromLocalStorage, setSmoothingFromLocalStorage] = useLocalStorage('scalar_smoothing'); + const parsedSmoothing = useMemo(() => { + if (query.smoothing != null) { + return parseSmoothing(query.smoothing); + } + return parseSmoothing(smoothingFromLocalStorage); + }, [query.smoothing, smoothingFromLocalStorage]); + const [smoothing, setSmoothing] = useState(parsedSmoothing); + useEffect(() => setSmoothingFromLocalStorage(String(smoothing)), [smoothing, setSmoothingFromLocalStorage]); const [xAxis, setXAxis] = useState(XAxis.Step); diff --git a/frontend/packages/core/src/resource/scalar/data.ts b/frontend/packages/core/src/resource/scalar/data.ts index fa7804cf3ac1ac8d05f122e71eaa365cdc25654b..fe47c37673b80f2d12a4a0126883a55f2987cb1b 100644 --- a/frontend/packages/core/src/resource/scalar/data.ts +++ b/frontend/packages/core/src/resource/scalar/data.ts @@ -123,3 +123,12 @@ export const nearestPoint = (data: Dataset[], runs: Run[], step: number) => item: nearestItem || [0, 0, 0, 0, 0] }; }); + +export const parseSmoothing = (value: unknown) => { + const parsedValue = Number.parseFloat(String(value)); + let smoothing = 0.6; + if (Number.isFinite(parsedValue) && parsedValue < 1 && parsedValue >= 0) { + smoothing = Math.round(parsedValue * 100) / 100; + } + return smoothing; +}; diff --git a/frontend/packages/core/src/utils/fetch.ts b/frontend/packages/core/src/utils/fetch.ts index 6968a67eb20841d5a8a3aecc71e12c3184f9a3ea..244ff0a195a2308d0d5fca7e6f502338a2453f97 100644 --- a/frontend/packages/core/src/utils/fetch.ts +++ b/frontend/packages/core/src/utils/fetch.ts @@ -1,3 +1,5 @@ +import type {TFunction} from 'i18next'; +import i18next from 'i18next'; import queryString from 'query-string'; const API_TOKEN_KEY: string = import.meta.env.SNOWPACK_PUBLIC_API_TOKEN_KEY; @@ -29,12 +31,17 @@ function addApiToken(options?: RequestInit): RequestInit | undefined { }; } -export const fetcher = async (url: string, options?: RequestInit): Promise => { - const res = await fetch(API_URL + url, addApiToken(options)); - const response = await res.json(); +interface SuccessData { + status: 0; + data: D; +} - return response && 'data' in response ? response.data : response; -}; +interface ErrorData { + status: number; + msg?: string; +} + +type Data = SuccessData | ErrorData; export type BlobResponse = { data: Blob; @@ -42,30 +49,77 @@ export type BlobResponse = { filename: string | null; }; -export const blobFetcher = async (url: string, options?: RequestInit): Promise => { - const res = await fetch(API_URL + url, addApiToken(options)); - const data = await res.blob(); - const disposition = res.headers.get('Content-Disposition'); - // support safari - if (!data.arrayBuffer) { - data.arrayBuffer = async () => - new Promise((resolve, reject) => { - const fileReader = new FileReader(); - fileReader.addEventListener('load', e => - e.target ? resolve(e.target.result as ArrayBuffer) : reject() - ); - fileReader.readAsArrayBuffer(data); - }); +function getT(): Promise { + return new Promise(resolve => { + // Bug of i18next + i18next.changeLanguage((undefined as unknown) as string).then(t => resolve(t)); + }); +} + +function logErrorAndReturnT(e: unknown) { + if (import.meta.env.MODE === 'development') { + console.error(e); // eslint-disable-line no-console + } + return getT(); +} + +export function fetcher(url: string, options?: RequestInit): Promise; +export function fetcher(url: string, options?: RequestInit): Promise; +export async function fetcher(url: string, options?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(API_URL + url, addApiToken(options)); + } catch (e) { + const t = await logErrorAndReturnT(e); + throw new Error(t('errors:network-error')); + } + + if (!res.ok) { + const t = await logErrorAndReturnT(res); + throw new Error(t([`errors:response-error.${res.status}`, 'errors:response-error.unknown'])); } - let filename: string | null = null; - if (disposition && disposition.indexOf('attachment') !== -1) { - const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(disposition); - if (matches != null && matches[1]) { - filename = matches[1].replace(/['"]/g, ''); + + let response: Data | T; + try { + if (res.headers.get('content-type')?.includes('application/json')) { + response = await res.json(); + if (response && 'status' in response) { + if (response.status !== 0) { + const t = await logErrorAndReturnT(response); + throw new Error((response as ErrorData).msg || t('errors:error')); + } else { + return (response as SuccessData).data; + } + } + return response; + } else { + const data = await res.blob(); + const disposition = res.headers.get('Content-Disposition'); + // support safari + if (!data.arrayBuffer) { + data.arrayBuffer = async () => + new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.addEventListener('load', e => + e.target ? resolve(e.target.result as ArrayBuffer) : reject() + ); + fileReader.readAsArrayBuffer(data); + }); + } + let filename: string | null = null; + if (disposition && disposition.indexOf('attachment') !== -1) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(disposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + } + } + return {data, type: res.headers.get('Content-Type'), filename}; } + } catch (e) { + const t = await logErrorAndReturnT(e); + throw new Error(t('errors:parse-error')); } - return {data, type: res.headers.get('Content-Type'), filename}; -}; +} export const cycleFetcher = async (urls: string[], options?: RequestInit): Promise => { return await Promise.all(urls.map(url => fetcher(url, options))); diff --git a/frontend/packages/core/src/utils/i18n.ts b/frontend/packages/core/src/utils/i18n.ts index 9105c0218023f8a6ea2d66c67e6e249aa23c079c..312427569aa321975a71a296a418f4715ed3d7d4 100644 --- a/frontend/packages/core/src/utils/i18n.ts +++ b/frontend/packages/core/src/utils/i18n.ts @@ -19,6 +19,9 @@ i18n.use(initReactI18next) ns: 'common', defaultNS: 'common', load: 'currentOnly', + react: { + useSuspense: false + }, interpolation: { escapeValue: false }, diff --git a/frontend/packages/demo/builder/io.ts b/frontend/packages/demo/builder/io.ts index df1801686c700d1c5da4901c07b471c949cf22b1..e3f0d115ee80060581b003ebc2b18876c11ad55e 100644 --- a/frontend/packages/demo/builder/io.ts +++ b/frontend/packages/demo/builder/io.ts @@ -4,8 +4,6 @@ import crypto, {BinaryLike} from 'crypto'; import fetch from 'node-fetch'; import {promises as fs} from 'fs'; -import mime from 'mime-types'; -import mkdirp from 'mkdirp'; import path from 'path'; import querystring from 'querystring'; @@ -110,6 +108,7 @@ export default class IO { contentType: string, options?: WriteOptions | WriteOptions['type'] ) { + const {default: mkdirp} = await import('mkdirp'); const type = 'string' === typeof options ? options : options?.type ?? 'json'; const fileDir = path.join(this.dataDir, IO.dataPath, filePath); @@ -117,6 +116,7 @@ export default class IO { let fileContent: Buffer; let extname: string; if (type === 'buffer') { + const {default: mime} = await import('mime-types'); extname = mime.extension(contentType) || ''; if (extname) { extname = '.' + extname; diff --git a/frontend/packages/demo/package.json b/frontend/packages/demo/package.json index dfa14c61d7c8431178a42eb1f4804f642f202bea..04fa3e81804bc411d889f6fcd5e43337174f500e 100644 --- a/frontend/packages/demo/package.json +++ b/frontend/packages/demo/package.json @@ -41,7 +41,7 @@ "get-port": "5.1.1", "mime-types": "2.1.27", "mkdirp": "1.0.4", - "node-fetch": "2.6.0", + "node-fetch": "2.6.1", "rimraf": "3.0.2", "ts-node": "9.0.0", "typescript": "4.0.2" diff --git a/frontend/packages/mock/middleware.ts b/frontend/packages/mock/middleware.ts index 453f48f7228d87c16634538f58f440095f613eb4..f9fd9e34af6656e08209d115e357f360566e2005 100644 --- a/frontend/packages/mock/middleware.ts +++ b/frontend/packages/mock/middleware.ts @@ -26,7 +26,10 @@ export default (options?: Options) => { } if (!method) { - res.status(404).send({}); + res.status(404).json({ + status: 1, + msg: 'Method does not exist' + }); return; } @@ -61,7 +64,10 @@ export default (options?: Options) => { } } } catch (e) { - res.status(500).send(e.message); + res.status(500).json({ + status: 1, + msg: e.message + }); // eslint-disable-next-line no-console console.error(e); } diff --git a/frontend/packages/server/index.ts b/frontend/packages/server/index.ts index 934d750a7e843f76121f2f33bb94a6cd1431157d..bf38abd0adaeb25ae72ee7346b8f3210a8452443 100644 --- a/frontend/packages/server/index.ts +++ b/frontend/packages/server/index.ts @@ -33,8 +33,12 @@ async function start() { }) ); } else if (isDemo) { - const {default: demo} = await import('@visualdl/demo'); - app.use(apiUrl, demo); + try { + const {default: demo} = await import('@visualdl/demo'); + app.use(apiUrl, demo); + } catch { + console.warn('Demo is not installed. Please rebuild server.'); + } } else if (isDev) { const {middleware: mock} = await import('@visualdl/mock'); app.use(apiUrl, mock({delay: delay ? () => Math.random() * delay : 0})); diff --git a/frontend/packages/server/package.json b/frontend/packages/server/package.json index 9cfd6dd1bc4eff05dd2b053f0ed8c8aa502c5f07..80637a8217a6f58a80d409a6db87504c4ba67796 100644 --- a/frontend/packages/server/package.json +++ b/frontend/packages/server/package.json @@ -37,7 +37,6 @@ ], "dependencies": { "@visualdl/core": "2.0.0", - "@visualdl/demo": "2.0.0", "dotenv": "8.2.0", "enhanced-resolve": "4.3.0", "express": "4.17.1", @@ -54,6 +53,9 @@ "ts-node": "9.0.0", "typescript": "4.0.2" }, + "optionalDependencies": { + "@visualdl/demo": "2.0.0" + }, "engines": { "node": ">=12", "npm": ">=6" diff --git a/frontend/scripts/install.sh b/frontend/scripts/install.sh index 8312c88ec67909a7d520e7af41001d3babae801c..b70e7506ef68df05e6d9512db2d58fa1a404fe27 100755 --- a/frontend/scripts/install.sh +++ b/frontend/scripts/install.sh @@ -28,3 +28,8 @@ export PATH=$PATH # yarn install yarn install --frozen-lockfile + +# re-install esbuild +# I don't know why... +# but this works in docker... +(cd node_modules/esbuild && rm -f stamp.txt && node install.js) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b0b3a979ac94f3957a680717d78cea79adea2c1d..f893b1b416fd3842e0749baffcf521557c4a4a26 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -9963,7 +9963,12 @@ node-fetch-npm@^2.0.2: json-parse-better-errors "^1.0.0" safe-buffer "^5.1.1" -node-fetch@2.6.0, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0: +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + +node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0: version "2.6.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== @@ -13028,10 +13033,10 @@ strong-log-transformer@^2.0.0: minimist "^1.2.0" through "^2.3.4" -styled-components@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.1.tgz#96dfb02a8025794960863b9e8e365e3b6be5518d" - integrity sha512-1ps8ZAYu2Husx+Vz8D+MvXwEwvMwFv+hqqUwhNlDN5ybg6A+3xyW1ECrAgywhvXapNfXiz79jJyU0x22z0FFTg== +styled-components@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.2.0.tgz#6dcb5aa8a629c84b8d5ab34b7167e3e0c6f7ed74" + integrity sha512-9qE8Vgp8C5cpGAIdFaQVAl89Zgx1TDM4Yf4tlHbO9cPijtpSXTMLHy9lmP0lb+yImhgPFb1AmZ1qMUubmg3HLg== dependencies: "@babel/helper-module-imports" "^7.0.0" "@babel/traverse" "^7.4.5" diff --git a/visualdl/server/app.py b/visualdl/server/app.py index dec358e51ad549bd400c67a77b3d9234614e8fe2..29ae6d26e9ffc82984d51074e8dd89145a94ea8b 100644 --- a/visualdl/server/app.py +++ b/visualdl/server/app.py @@ -98,7 +98,10 @@ def create_app(args): @app.route(public_path + '/') def index(): - return redirect(public_path + '/index', code=302) + query_string = '' + if request.query_string: + query_string = '?' + request.query_string.decode() + return redirect(public_path + '/index' + query_string, code=302) @app.route(public_path + '/') def serve_static(filename):