1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-01-17 08:48:05 +00:00

Implement client-side UI config fetching

This commit is contained in:
thatmattlove 2021-09-09 17:59:26 -07:00
parent d1dae9d6ba
commit 070a07d065
21 changed files with 387 additions and 145 deletions

View file

@ -26,6 +26,7 @@ from hyperglass.api.routes import (
queries,
routers,
communities,
ui_props,
import_certificate,
)
from hyperglass.exceptions import HyperglassError
@ -172,7 +173,7 @@ def _custom_openapi():
CORS_ORIGINS = params.cors_origins.copy()
if params.developer_mode:
CORS_ORIGINS.append(URL_DEV)
CORS_ORIGINS = [*CORS_ORIGINS, URL_DEV, "http://localhost:3000"]
# CORS Configuration
app.add_middleware(
@ -240,6 +241,13 @@ app.add_api_route(
response_class=JSONResponse,
)
app.add_api_route(
path="/ui/props/",
endpoint=ui_props,
methods=["GET", "OPTIONS"],
response_class=JSONResponse,
)
# Enable certificate import route only if a device using
# hyperglass-agent is defined.
if [n for n in devices.all_nos if n in TRANSPORT_REST]:

View file

@ -20,7 +20,7 @@ from hyperglass.api.tasks import process_headers, import_public_key
from hyperglass.constants import __version__
from hyperglass.exceptions import HyperglassError
from hyperglass.models.api import Query, EncodedRequest
from hyperglass.configuration import REDIS_CONFIG, params, devices
from hyperglass.configuration import REDIS_CONFIG, params, devices, frontend_params
from hyperglass.execution.main import execute
# Local
@ -264,4 +264,9 @@ async def info():
}
endpoints = [query, docs, routers, info]
async def ui_props():
"""Serve UI configration."""
return frontend_params
endpoints = [query, docs, routers, info, ui_props]

View file

@ -65,7 +65,7 @@ def build_ui(timeout: int) -> None:
"""Create a new UI build."""
try:
# Project
from hyperglass.configuration import CONFIG_PATH, params, frontend_params
from hyperglass.configuration import CONFIG_PATH, params
from hyperglass.util.frontend import build_frontend
from hyperglass.compat._asyncio import aiorun
except ImportError as e:
@ -84,7 +84,7 @@ def build_ui(timeout: int) -> None:
dev_mode=params.developer_mode,
dev_url=f"http://localhost:{str(params.listen_port)}/",
prod_url="/api/",
params=frontend_params,
params=params.export_dict(),
force=True,
app_path=CONFIG_PATH,
)

View file

@ -9,6 +9,7 @@ export * from './header';
export * from './help';
export * from './label';
export * from './layout';
export * from './loadError';
export * from './loading';
export * from './lookingGlass';
export * from './markdown';

View file

@ -1,17 +1,20 @@
import { useRef } from 'react';
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import { Flex } from '@chakra-ui/react';
import { isSafari } from 'react-device-detect';
import { If, Debugger, Greeting, Footer, Header } from '~/components';
import { useConfig } from '~/context';
import { useLGState, useLGMethods } from '~/hooks';
import { useLGState, useLGMethods, useGoogleAnalytics } from '~/hooks';
import { ResetButton } from './resetButton';
import type { TFrame } from './types';
export const Frame: React.FC<TFrame> = (props: TFrame) => {
const { developer_mode } = useConfig();
export const Frame = (props: TFrame): JSX.Element => {
const router = useRouter();
const { developer_mode, google_analytics } = useConfig();
const { isSubmitting } = useLGState();
const { resetForm } = useLGMethods();
const { initialize, trackPage } = useGoogleAnalytics();
const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement);
@ -21,6 +24,15 @@ export const Frame: React.FC<TFrame> = (props: TFrame) => {
resetForm();
}
useEffect(() => {
initialize(google_analytics, developer_mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
trackPage(router.pathname);
}, [router.pathname, trackPage]);
return (
<>
<Flex

View file

@ -0,0 +1,81 @@
import {
Box,
Alert,
Button,
VStack,
AlertIcon,
AlertTitle,
AlertDescription,
} from '@chakra-ui/react';
import { NoConfig } from './noconfig';
import type { CenterProps } from '@chakra-ui/react';
import type { ConfigLoadError } from '~/util';
interface LoadErrorProps extends CenterProps {
/**
* Error thrown by `getHyperglassConfig` when any errors occur.
*/
error: ConfigLoadError;
/**
* Callback to retry the config fetch.
*/
retry: () => void;
/**
* If `true`, the UI is currently retrying the config fetch.
*/
inProgress: boolean;
}
/**
* Error component to be displayed when the hyperglass UI is unable to communicate with the
* hyperglass API to retrieve its configuration.
*/
export const LoadError = (props: LoadErrorProps): JSX.Element => {
const { error, retry, inProgress, ...rest } = props;
return (
<NoConfig {...rest}>
<Alert
status="error"
height="200px"
variant="subtle"
borderRadius="lg"
textAlign="center"
alignItems="center"
flexDirection="column"
justifyContent="center"
maxW={{ base: '95%', md: '80%', lg: '60%', xl: '40%' }}
>
<Box pos="absolute" right={0} top={0} m={4}>
<Button
size="sm"
variant="outline"
colorScheme="red"
isLoading={inProgress}
onClick={() => retry()}
>
Retry
</Button>
</Box>
<AlertIcon boxSize={8} mr={0} />
<VStack spacing={2}>
<AlertTitle mt={4} mb={1} fontSize="xl">
Error Loading Configuration
</AlertTitle>
<AlertDescription>
<span>{error.baseMessage}</span>
<Box as="span" fontWeight="bold">
{` ${error.url}`}
</Box>
</AlertDescription>
{typeof error.detail !== 'undefined' && (
<AlertDescription fontWeight="light">{error.detail}</AlertDescription>
)}
</VStack>
</Alert>
</NoConfig>
);
};

View file

@ -1,25 +1,10 @@
import { Flex, Spinner } from '@chakra-ui/react';
import { Spinner } from '@chakra-ui/react';
import { NoConfig } from './noconfig';
import type { LoadableBaseOptions } from 'next/dynamic';
export const Loading: LoadableBaseOptions['loading'] = () => (
<Flex flexDirection="column" minHeight="100vh" w="100%">
<Flex
px={2}
py={0}
w="100%"
bg="white"
color="black"
flex="1 1 auto"
textAlign="center"
alignItems="center"
justifyContent="center"
flexDirection="column"
css={{
'@media (prefers-color-scheme: dark)': { backgroundColor: 'black', color: 'white' },
}}
>
<Spinner color="primary.500" w="6rem" h="6rem" />
</Flex>
</Flex>
);
export const Loading = (): JSX.Element => {
return (
<NoConfig color="#118ab2">
<Spinner boxSize="8rem" />
</NoConfig>
);
};

View file

@ -0,0 +1,28 @@
import { Center, Flex, ChakraProvider, extendTheme } from '@chakra-ui/react';
import { mode } from '@chakra-ui/theme-tools';
import type { CenterProps } from '@chakra-ui/react';
const theme = extendTheme({
useSystemColorMode: true,
styles: {
global: props => ({
html: { scrollBehavior: 'smooth', height: '-webkit-fill-available' },
body: {
background: mode('white', 'black')(props),
color: mode('black', 'white')(props),
overflowX: 'hidden',
},
}),
},
});
export const NoConfig = (props: CenterProps): JSX.Element => {
return (
<ChakraProvider theme={theme}>
<Flex flexDirection="column" minHeight="100vh" w="100%">
<Center flex="1 1 auto" {...props} />
</Flex>
</ChakraProvider>
);
};

View file

@ -5,6 +5,7 @@ export * from './useDirective';
export * from './useDNSQuery';
export * from './useGoogleAnalytics';
export * from './useGreeting';
export * from './useHyperglassConfig';
export * from './useLGQuery';
export * from './useLGState';
export * from './useOpposingColor';

View file

@ -0,0 +1,61 @@
import { useState } from 'react';
import { useQuery } from 'react-query';
import { getHyperglassConfig } from '~/util';
import type { UseQueryResult } from 'react-query';
import type { ConfigLoadError } from '~/util';
import type { IConfig } from '~/types';
type UseHyperglassConfig = UseQueryResult<IConfig, ConfigLoadError> & {
/**
* Initial configuration load has failed.
*/
initFailed: boolean;
/**
* If `true`, the initial loading component should be rendered.
*/
isLoadingInitial: boolean;
/**
* If `true`, an error component should be rendered.
*/
showError: boolean;
/**
* Data has been loaded and there are no errors.
*/
ready: boolean;
};
/**
* Retrieve and cache hyperglass's UI configuration from the hyperglass API.
*/
export function useHyperglassConfig(): UseHyperglassConfig {
// Track whether or not the initial configuration load has failed. If it has not (default), the
// UI will display the `<Loading/>` component. If it has failed, the `<LoadError/>` component
// will be displayed, which will also show the loading state.
const [initFailed, setInitFailed] = useState<boolean>(false);
const query = useQuery<IConfig, ConfigLoadError>({
queryKey: 'hyperglass-ui-config',
queryFn: getHyperglassConfig,
refetchOnWindowFocus: false,
refetchInterval: 10000,
cacheTime: Infinity,
onError: () => {
if (!initFailed) {
setInitFailed(true);
}
},
onSuccess: () => {
if (initFailed) {
setInitFailed(false);
}
},
});
const isLoadingInitial = !initFailed && query.isLoading && !query.isError;
const showError = query.isError || (initFailed && query.isLoading);
const ready = query.isSuccess && !query.isLoading;
return { initFailed, isLoadingInitial, showError, ready, ...query };
}

View file

@ -96,7 +96,13 @@ class MethodsInstance {
try {
result = JSON.parse(JSON.stringify(obj));
} catch (err) {
console.error(err.message);
let error;
if (err instanceof Error) {
error = err.message;
} else {
error = String(err);
}
console.error(error);
}
return result;
}

View file

@ -1,17 +1,15 @@
const envVars = require('/tmp/hyperglass.env.json');
const { configFile } = envVars;
const config = require(String(configFile));
const envData = require('/tmp/hyperglass.env.json');
module.exports = {
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
reactStrictMode: true,
poweredByHeader: false,
env: {
_NODE_ENV_: config.NODE_ENV,
_HYPERGLASS_URL_: config._HYPERGLASS_URL_,
_HYPERGLASS_CONFIG_: config._HYPERGLASS_CONFIG_,
_HYPERGLASS_FAVICONS_: config._HYPERGLASS_FAVICONS_,
},
env: { ...envData },
typescript: {
ignoreBuildErrors: true,
},
};
module.exports = nextConfig;

View file

@ -2,20 +2,22 @@
const express = require('express');
const proxyMiddleware = require('http-proxy-middleware');
const next = require('next');
const envVars = require('/tmp/hyperglass.env.json');
const { configFile } = envVars;
const config = require(String(configFile));
const config = require('/tmp/hyperglass.env.json');
const { NODE_ENV: env, _HYPERGLASS_URL_: envUrl } = config;
const {
env: { NODE_ENV },
hyperglass: { url },
} = config;
const devProxy = {
'/api/query/': { target: envUrl + 'api/query/', pathRewrite: { '^/api/query/': '' } },
'/images': { target: envUrl + 'images', pathRewrite: { '^/images': '' } },
'/custom': { target: envUrl + 'custom', pathRewrite: { '^/custom': '' } },
'/api/query/': { target: url + 'api/query/', pathRewrite: { '^/api/query/': '' } },
'/ui/props/': { target: url + 'ui/props/', pathRewrite: { '^/ui/props/': '' } },
'/images': { target: url + 'images', pathRewrite: { '^/images': '' } },
'/custom': { target: url + 'custom', pathRewrite: { '^/custom': '' } },
};
const port = parseInt(process.env.PORT, 10) || 3000;
const dev = env !== 'production';
const dev = NODE_ENV !== 'production';
const app = next({
dir: '.', // base directory where everything is, could move to src later
dev,
@ -43,7 +45,7 @@ app
if (err) {
throw err;
}
console.log(`> Ready on port ${port} [${env}]`);
console.log(`> Ready on port ${port} [${NODE_ENV}]`);
});
})
.catch(err => {

View file

@ -1,33 +1,16 @@
import { useEffect } from 'react';
import Head from 'next/head';
import { HyperglassProvider } from '~/context';
import { useGoogleAnalytics } from '~/hooks';
import { IConfig } from '~/types';
import { QueryClient, QueryClientProvider } from 'react-query';
import type { AppProps, AppInitialProps, AppContext } from 'next/app';
import type { AppProps } from 'next/app';
if (process.env.NODE_ENV === 'development') {
require('@hookstate/devtools');
}
type TApp = { config: IConfig };
const queryClient = new QueryClient();
type GetInitialPropsReturn<IP> = AppProps & AppInitialProps & { appProps: IP };
type NextApp<IP> = React.FC<GetInitialPropsReturn<IP>> & {
getInitialProps(c?: AppContext): Promise<{ appProps: IP }>;
};
const App: NextApp<TApp> = (props: GetInitialPropsReturn<TApp>) => {
const { Component, pageProps, appProps, router } = props;
const { config } = appProps;
const { initialize, trackPage } = useGoogleAnalytics();
initialize(config.google_analytics, config.developer_mode);
useEffect(() => {
router.events.on('routeChangeComplete', trackPage);
}, [router.events, trackPage]);
const App = (props: AppProps): JSX.Element => {
const { Component, pageProps } = props;
return (
<>
@ -44,16 +27,11 @@ const App: NextApp<TApp> = (props: GetInitialPropsReturn<TApp>) => {
content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0"
/>
</Head>
<HyperglassProvider config={config}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</HyperglassProvider>
</QueryClientProvider>
</>
);
};
App.getInitialProps = async function getInitialProps() {
const config = process.env._HYPERGLASS_CONFIG_ as unknown as IConfig;
return { appProps: { config } };
};
export default App;

View file

@ -1,4 +1,5 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
import type { DocumentContext, DocumentInitialProps } from 'next/document';
class MyDocument extends Document {

View file

@ -1,9 +1,12 @@
import Head from 'next/head';
import dynamic from 'next/dynamic';
import { Meta, Loading } from '~/components';
import { Meta, Loading, If, LoadError } from '~/components';
import { HyperglassProvider } from '~/context';
import { useHyperglassConfig } from '~/hooks';
import { getFavicons } from '~/util';
import type { GetStaticProps } from 'next';
import type { Favicon, FaviconComponent } from '~/types';
import type { FaviconComponent } from '~/types';
const Layout = dynamic<Dict>(() => import('~/components').then(i => i.Layout), {
loading: Loading,
@ -13,8 +16,11 @@ interface TIndex {
favicons: FaviconComponent[];
}
const Index: React.FC<TIndex> = (props: TIndex) => {
const Index = (props: TIndex): JSX.Element => {
const { favicons } = props;
const { data, error, isLoading, ready, refetch, showError, isLoadingInitial } =
useHyperglassConfig();
return (
<>
<Head>
@ -23,23 +29,24 @@ const Index: React.FC<TIndex> = (props: TIndex) => {
return <link rel={rel} href={href} type={type} key={idx} />;
})}
</Head>
<Meta />
<Layout />
<If c={isLoadingInitial}>
<Loading />
</If>
<If c={showError}>
<LoadError error={error!} retry={refetch} inProgress={isLoading} />
</If>
<If c={ready}>
<HyperglassProvider config={data!}>
<Meta />
<Layout />
</HyperglassProvider>
</If>
</>
);
};
export const getStaticProps: GetStaticProps<TIndex> = async () => {
const faviconConfig = process.env._HYPERGLASS_FAVICONS_ as unknown as Favicon[];
const favicons = faviconConfig.map(icon => {
const { image_format, dimensions, prefix } = icon;
let { rel } = icon;
if (rel === null) {
rel = '';
}
const src = `/images/favicons/${prefix}-${dimensions[0]}x${dimensions[1]}.${image_format}`;
return { rel, href: src, type: `image/${image_format}` };
});
const favicons = await getFavicons();
return {
props: { favicons },
};

View file

@ -35,7 +35,7 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"types/globals.d.ts",
"types/*.d.ts",
"next.config.js",
"nextdev.js"
]

View file

@ -65,4 +65,13 @@ declare global {
Omit<MotionProps, keyof T> & { transition?: MotionProps['transition'] };
type MeronexIcon = import('@meronex/icons').IconBaseProps;
type RequiredProps<T> = { [P in keyof T]-?: Exclude<T[P], undefined> };
declare namespace NodeJS {
export interface ProcessEnv {
hyperglass: { favicons: import('./config').Favicon[]; version: string };
buildId: string;
}
}
}

View file

@ -0,0 +1,69 @@
import { isObject } from '~/types';
import type { IConfig, FaviconComponent } from '~/types';
export class ConfigLoadError extends Error {
public url: string = '/ui/props/';
public detail?: string;
public baseMessage: string;
constructor(detail?: string) {
super();
this.detail = detail;
this.baseMessage = `Unable to connect to hyperglass at`;
this.message = `${this.baseMessage} '${this.url}'`;
console.error(this);
}
public toString(): string {
if (typeof this.detail !== 'undefined') {
return `${this.message} (${this.detail})`;
}
return this.message;
}
}
export async function getHyperglassConfig(): Promise<IConfig> {
let mode: RequestInit['mode'];
if (process.env.NODE_ENV === 'production') {
mode = 'same-origin';
} else if (process.env.NODE_ENV === 'development') {
mode = 'cors';
}
const options: RequestInit = { method: 'GET', mode, headers: { 'user-agent': 'hyperglass-ui' } };
try {
const response = await fetch('/ui/props/', options);
const data = await response.json();
if (!response.ok) {
throw response;
}
if (isObject(data)) {
return data as IConfig;
}
} catch (error) {
if (error instanceof TypeError) {
throw new ConfigLoadError('Network Connection Error');
}
if (error instanceof Response) {
throw new ConfigLoadError(`${error.status}: ${error.statusText}`);
}
if (error instanceof Error) {
throw new ConfigLoadError();
}
throw new ConfigLoadError(String(error));
}
throw new ConfigLoadError('Unknown Error');
}
export async function getFavicons(): Promise<FaviconComponent[]> {
const { favicons: faviconConfig } = process.env.hyperglass;
return faviconConfig.map(icon => {
const { image_format, dimensions, prefix } = icon;
const [w, h] = dimensions;
const rel = icon.rel ?? '';
const src = `/images/favicons/${prefix}-${w}x${h}.${image_format}`;
return { rel, href: src, type: `image/${image_format}` };
});
}

View file

@ -1,2 +1,3 @@
export * from './common';
export * from './config';
export * from './theme';

View file

@ -245,6 +245,7 @@ async def build_frontend( # noqa: C901
app_path: Path,
force: bool = False,
timeout: int = 180,
full: bool = False,
) -> bool:
"""Perform full frontend UI build process.
@ -273,32 +274,35 @@ async def build_frontend( # noqa: C901
"""
# Standard Library
import hashlib
import tempfile
# Third Party
from favicons import Favicons
from favicons import Favicons # type:ignore
# Project
from hyperglass.constants import __version__
log.info("Starting UI build")
# Create temporary file. json file extension is added for easy
# webpack JSON parsing.
env_file = Path("/tmp/hyperglass.env.json") # noqa: S108
package_json = await read_package_json()
env_vars = {
"_HYPERGLASS_CONFIG_": params,
"_HYPERGLASS_VERSION_": __version__,
"_HYPERGLASS_PACKAGE_JSON_": package_json,
"_HYPERGLASS_APP_PATH_": str(app_path),
"hyperglass": {"version": __version__},
"env": {},
}
# Set NextJS production/development mode and base URL based on
# developer_mode setting.
if dev_mode:
env_vars.update({"NODE_ENV": "development", "_HYPERGLASS_URL_": dev_url})
env_vars["env"].update({"NODE_ENV": "development"})
env_vars["hyperglass"].update({"url": dev_url})
else:
env_vars.update({"NODE_ENV": "production", "_HYPERGLASS_URL_": prod_url})
env_vars["env"].update({"NODE_ENV": "production"})
env_vars["hyperglass"].update({"url": prod_url})
# Check if hyperglass/ui/node_modules has been initialized. If not,
# initialize it.
@ -328,64 +332,49 @@ async def build_frontend( # noqa: C901
) as favicons:
await favicons.generate()
log.debug("Generated {} favicons", favicons.completed)
env_vars.update({"_HYPERGLASS_FAVICONS_": favicons.formats()})
env_json = json.dumps(env_vars, default=str)
env_vars["hyperglass"].update({"favicons": favicons.formats()})
build_data = {
"params": params,
"version": __version__,
"package_json": package_json,
}
build_json = json.dumps(build_data, default=str)
# Create SHA256 hash from all parameters passed to UI, use as
# build identifier.
build_id = hashlib.sha256(env_json.encode()).hexdigest()
build_id = hashlib.sha256(build_json.encode()).hexdigest()
# Read hard-coded environment file from last build. If build ID
# matches this build's ID, don't run a new build.
if env_file.exists() and not force:
with env_file.open("r") as ef:
ef_id = json.load(ef).get("buildId", "empty")
log.debug("Previous Build ID: {id}", id=ef_id)
log.debug("Previous Build ID: {}", ef_id)
if ef_id == build_id:
log.debug(
"UI parameters unchanged since last build, skipping UI build..."
)
return True
# Create temporary file. json file extension is added for easy
# webpack JSON parsing.
temp_file = tempfile.NamedTemporaryFile(
mode="w+", prefix="hyperglass_", suffix=".json", delete=not dev_mode
)
env_vars["buildId"] = build_id
log.info("Starting UI build...")
log.debug(
f"Created temporary UI config file: '{temp_file.name}' for build {build_id}"
)
env_file.write_text(json.dumps(env_vars, default=str))
log.debug("Wrote UI environment file '{}'", str(env_file))
with Path(temp_file.name).open("w+") as temp:
temp.write(env_json)
# Initiate Next.JS export process.
if any((not dev_mode, force, full)):
initialize_result = await node_initial(timeout, dev_mode)
build_result = await build_ui(app_path=app_path)
# Write "permanent" file (hard-coded named) for Node to read.
env_file.write_text(
json.dumps({"configFile": temp_file.name, "buildId": build_id})
)
if initialize_result:
log.debug(initialize_result)
elif initialize_result == "":
log.debug("Re-initialized node_modules")
# While temporary file is still open, initiate UI build process.
if not dev_mode or force:
initialize_result = await node_initial(timeout, dev_mode)
build_result = await build_ui(app_path=app_path)
if initialize_result:
log.debug(initialize_result)
elif initialize_result == "":
log.debug("Re-initialized node_modules")
if build_result:
log.success("Completed UI build")
elif dev_mode and not force:
log.debug("Running in developer mode, did not build new UI files")
if build_result:
log.success("Completed UI build")
elif dev_mode and not force:
log.debug("Running in developer mode, did not build new UI files")
migrate_images(app_path, params)