Closes #175: Remove usage of hyperglass.env.json

This commit is contained in:
thatmattlove 2021-12-08 15:24:07 -07:00
parent b49b6ea58e
commit 8aeb8036ff
17 changed files with 224 additions and 195 deletions

View file

@ -0,0 +1,4 @@
node_modules
dist
.next/
favicon-formats.ts

View file

@ -1,4 +1,5 @@
.DS_Store
.env*
*.tsbuildinfo
# dev/test files
TODO.txt

View file

@ -6,3 +6,4 @@ package-lock.json
.eslintrc
tsconfig.json
.next/
favicon-formats.ts

View file

@ -0,0 +1,11 @@
import type { Favicon as FaviconProps } from '~/types';
/**
* Render a `<link/>` element to reference a server-side favicon.
*/
export const Favicon = (props: FaviconProps): JSX.Element => {
const { image_format, dimensions, prefix, rel } = props;
const [w, h] = dimensions;
const src = `/images/favicons/${prefix}-${w}x${h}.${image_format}`;
return <link rel={rel ?? ''} href={src} type={`image/${image_format}`} />;
};

View file

@ -2,6 +2,7 @@ export * from './card';
export * from './codeBlock';
export * from './countdown';
export * from './debugger';
export * from './favicon';
export * from './footer';
export * from './form';
export * from './greeting';

View file

@ -4,7 +4,7 @@ import { useTheme } from '@chakra-ui/react';
import { useConfig } from '~/context';
import { googleFontUrl } from '~/util';
export const Meta: React.FC = () => {
export const Meta = (): JSX.Element => {
const config = useConfig();
const { fonts } = useTheme();
const [location, setLocation] = useState('/');
@ -12,22 +12,6 @@ export const Meta: React.FC = () => {
const {
siteTitle: title = 'hyperglass',
siteDescription: description = 'Network Looking Glass',
siteKeywords: keywords = [
'hyperglass',
'looking glass',
'lg',
'peer',
'peering',
'ipv4',
'ipv6',
'transit',
'community',
'communities',
'bgp',
'routing',
'network',
'isp',
],
} = useConfig();
const siteName = `${title} - ${description}`;
@ -42,8 +26,7 @@ export const Meta: React.FC = () => {
return (
<Head>
<title>{title}</title>
<meta name="language" content="en" />
<title key="title">{title}</title>
<meta name="url" content={location} />
<meta name="og:title" content={title} />
<meta name="og:url" content={location} />
@ -52,8 +35,7 @@ export const Meta: React.FC = () => {
<meta name="description" content={description} />
<meta property="og:image:alt" content={siteName} />
<meta name="og:description" content={description} />
<meta name="keywords" content={keywords.join(', ')} />
<meta name="hg-version" content={config.version} />
<meta name="hyperglass-version" content={config.version} />
</Head>
);
};

View file

@ -0,0 +1 @@
import type { Favicon } from '~/types';export default [{"dimensions": [64, 64], "image_format": "ico", "prefix": "favicon", "rel": null}, {"dimensions": [16, 16], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [32, 32], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [64, 64], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [96, 96], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [180, 180], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [57, 57], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [60, 60], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [72, 72], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [76, 76], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [114, 114], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [120, 120], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [144, 144], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [152, 152], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [167, 167], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [180, 180], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [70, 70], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [270, 270], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [310, 310], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [310, 150], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [196, 196], "image_format": "png", "prefix": "favicon", "rel": "shortcut icon"}] as Favicon[];

View file

@ -1,12 +1,9 @@
const envData = require('/tmp/hyperglass.env.json');
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
reactStrictMode: true,
poweredByHeader: false,
env: { ...envData },
typescript: {
ignoreBuildErrors: true,
},

View file

@ -2,21 +2,9 @@
const express = require('express');
const proxyMiddleware = require('http-proxy-middleware');
const next = require('next');
const config = require('/tmp/hyperglass.env.json');
const {
env: { NODE_ENV },
hyperglass: { url },
} = config;
const devProxy = {
'/api/query/': { target: url + 'api/query/', pathRewrite: { '^/api/query/': '' } },
'/ui/props/': { target: url + 'ui/props/', pathRewrite: { '^/ui/props/': '' } },
'/images': { target: url + 'images', pathRewrite: { '^/images': '' } },
};
const port = parseInt(process.env.PORT, 10) || 3000;
const dev = NODE_ENV !== 'production';
const dev = process.env.NODE_ENV !== 'production';
const app = next({
dir: '.', // base directory where everything is, could move to src later
dev,
@ -30,8 +18,20 @@ app
.then(() => {
server = express();
const devProxy = {
'/api/query/': {
target: process.env.HYPERGLASS_URL + 'api/query/',
pathRewrite: { '^/api/query/': '' },
},
'/ui/props/': {
target: process.env.HYPERGLASS_URL + 'ui/props/',
pathRewrite: { '^/ui/props/': '' },
},
'/images': { target: process.env.HYPERGLASS_URL + 'images', pathRewrite: { '^/images': '' } },
};
// Set up the proxy.
if (dev && devProxy) {
if (dev) {
Object.keys(devProxy).forEach(context => {
server.use(proxyMiddleware(context, devProxy[context]));
});
@ -44,7 +44,7 @@ app
if (err) {
throw err;
}
console.log(`> Ready on port ${port} [${NODE_ENV}]`);
console.log(`> Ready on port ${port} [${process.env.NODE_ENV}]`);
});
})
.catch(err => {

View file

@ -1,4 +1,3 @@
import Head from 'next/head';
import { QueryClient, QueryClientProvider } from 'react-query';
import type { AppProps } from 'next/app';
@ -9,24 +8,9 @@ const App = (props: AppProps): JSX.Element => {
const { Component, pageProps } = props;
return (
<>
<Head>
<title>hyperglass</title>
<meta httpEquiv="Content-Type" content="text/html" />
<meta charSet="UTF-8" />
<meta name="og:type" content="website" />
<meta name="og:image" content="/images/opengraph.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0"
/>
</Head>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
);
};

View file

@ -1,4 +1,6 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { Favicon } from '~/components';
import favicons from '../favicon-formats';
import type { DocumentContext, DocumentInitialProps } from 'next/document';
@ -12,10 +14,23 @@ class MyDocument extends Document {
return (
<Html lang="en">
<Head>
<meta name="language" content="en" />
<meta httpEquiv="Content-Type" content="text/html" />
<meta charSet="UTF-8" />
<meta name="og:type" content="website" />
<meta name="og:image" content="/images/opengraph.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0"
/>
<link rel="dns-prefetch" href="//fonts.gstatic.com" />
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="preconnect" href="https://www.google-analytics.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="true" />
{favicons.map((favicon, idx) => (
<Favicon key={idx} {...favicon} />
))}
</Head>
<body>
<Main />

View file

@ -1,34 +1,20 @@
import Head from 'next/head';
import dynamic from 'next/dynamic';
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 { FaviconComponent } from '~/types';
import type { NextPage } from 'next';
const Layout = dynamic<Dict>(() => import('~/components').then(i => i.Layout), {
loading: Loading,
});
interface TIndex {
favicons: FaviconComponent[];
}
const Index = (props: TIndex): JSX.Element => {
const { favicons } = props;
const Index: NextPage = () => {
const { data, error, isLoading, ready, refetch, showError, isLoadingInitial } =
useHyperglassConfig();
return (
<>
<Head>
{favicons.map((icon, idx) => {
const { rel, href, type } = icon;
return <link rel={rel} href={href} type={type} key={idx} />;
})}
</Head>
<If c={isLoadingInitial}>
<Loading />
</If>
@ -45,11 +31,4 @@ const Index = (props: TIndex): JSX.Element => {
);
};
export const getStaticProps: GetStaticProps<TIndex> = async () => {
const favicons = await getFavicons();
return {
props: { favicons },
};
};
export default Index;

View file

@ -1,8 +1,6 @@
import type { Theme } from './theme';
import type { CamelCasedPropertiesDeep, CamelCasedProperties } from 'type-fest';
// export type QueryFields = 'query_type' | 'query_target' | 'query_location' | 'query_vrf';
type Side = 'left' | 'right';
export type ParsedDataField = [string, keyof Route, 'left' | 'right' | 'center' | null];
@ -189,12 +187,6 @@ export interface Favicon {
prefix: string;
}
export interface FaviconComponent {
rel: string;
href: string;
type: string;
}
export type Config = CamelCasedPropertiesDeep<_ConfigDeep> & CamelCasedProperties<_ConfigShallow>;
export type ThemeConfig = CamelCasedProperties<_ThemeConfig>;
export type Content = CamelCasedProperties<_Content>;

View file

@ -1,6 +1,6 @@
import { isObject } from '~/types';
import type { Config, FaviconComponent } from '~/types';
import type { Config } from '~/types';
export class ConfigLoadError extends Error {
public url: string = '/ui/props/';
@ -56,14 +56,3 @@ export async function getHyperglassConfig(): Promise<Config> {
}
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

@ -2,8 +2,8 @@
# Standard Library
import shutil
import typing as t
from queue import Queue
from typing import List, Tuple, Union, Iterable, Optional, Generator
from pathlib import Path
from threading import Thread
@ -11,14 +11,8 @@ from threading import Thread
from hyperglass.log import log
async def move_files(src: Path, dst: Path, files: Iterable[Path]) -> Tuple[str]: # noqa: C901
"""Move iterable of files from source to destination.
Arguments:
src {Path} -- Current directory of files
dst {Path} -- Target destination directory
files {Iterable} -- Iterable of files
"""
async def move_files(src: Path, dst: Path, files: t.Iterable[Path]) -> t.Tuple[str]: # noqa: C901
"""Move iterable of files from source to destination."""
def error(*args, **kwargs):
msg = ", ".join(args)
@ -39,7 +33,7 @@ async def move_files(src: Path, dst: Path, files: Iterable[Path]) -> Tuple[str]:
except TypeError:
raise error("{p} is not a valid path", p=dst)
if not isinstance(files, (List, Tuple, Generator)):
if not isinstance(files, (t.List, t.Tuple, t.Generator)):
raise error(
"{fa} must be an iterable (list, tuple, or generator). Received {f}",
fa="Files argument",
@ -95,7 +89,7 @@ class FileCopy(Thread):
pass
def copyfiles(src_files: Iterable[Path], dst_files: Iterable[Path]):
def copyfiles(src_files: t.Iterable[Path], dst_files: t.Iterable[Path]):
"""Copy iterable of files from source to destination with threading."""
queue = Queue()
threads = ()
@ -131,7 +125,7 @@ def copyfiles(src_files: Iterable[Path], dst_files: Iterable[Path]):
return True
def check_path(path: Union[Path, str], mode: str = "r", create: bool = False) -> Optional[Path]:
def check_path(path: t.Union[Path, str], mode: str = "r", create: bool = False) -> t.Optional[Path]:
"""Verify if a path exists and is accessible."""
result = None
@ -157,3 +151,30 @@ def check_path(path: Union[Path, str], mode: str = "r", create: bool = False) ->
pass
return result
def dotenv_to_dict(dotenv: t.Union[Path, str]) -> t.Dict[str, str]:
"""Convert a .env file to a Python dict."""
if not isinstance(dotenv, (Path, str)):
raise TypeError("Argument 'file' must be a Path object or string")
result = {}
data = ""
if isinstance(dotenv, Path):
if not dotenv.exists():
raise FileNotFoundError("{!r} does not exist", str(dotenv))
with dotenv.open("r") as f:
data = f.read()
else:
data = dotenv
for line in (line for line in (line.strip() for line in data.splitlines()) if line):
parts = line.split("=")
if len(parts) != 2:
raise TypeError(
f"Line {line!r} is improperly formatted. "
"Expected a key/value pair such as 'key=value'"
)
key, value = line.split("=")
result[key.strip()] = value.strip()
return result

View file

@ -14,7 +14,7 @@ from pathlib import Path
from hyperglass.log import log
# Local
from .files import copyfiles, check_path
from .files import copyfiles, check_path, dotenv_to_dict
if t.TYPE_CHECKING:
# Project
@ -239,6 +239,26 @@ def migrate_images(app_path: Path, params: "UIParameters"):
return copyfiles(src_files, dst_files)
def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None:
"""Create a TypeScript file in the `ui` directory containing favicon formats.
This file should stay the same, unless the favicons library updates
supported formats.
"""
# Standard Library
from collections import OrderedDict
file = Path(__file__).parent.parent / "ui" / "favicon-formats.ts"
# Sort each favicon definition to ensure the result stays the same
# time the UI build runs.
ordered = json.dumps([OrderedDict(sorted(fmt.items())) for fmt in formats])
data = "import type {{ Favicon }} from '~/types';export default {} as Favicon[];".format(
ordered
)
file.write_text(data)
async def build_frontend( # noqa: C901
dev_mode: bool,
dev_url: str,
@ -249,19 +269,7 @@ async def build_frontend( # noqa: C901
timeout: int = 180,
full: bool = False,
) -> bool:
"""Perform full frontend UI build process.
Securely creates temporary file, writes frontend configuration
parameters to file as JSON. Then writes the name of the temporary
file to /tmp/hyperglass.env.json as {"configFile": <file_name> }.
Webpack reads /tmp/hyperglass.env.json, loads the temporary file,
and sets its contents to Node environment variables during the build
process.
After the build is successful, the temporary file is automatically
closed during garbage collection.
"""
"""Perform full frontend UI build process."""
# Standard Library
import hashlib
@ -273,24 +281,18 @@ async def build_frontend( # noqa: C901
# Create temporary file. json file extension is added for easy
# webpack JSON parsing.
env_file = Path("/tmp/hyperglass.env.json") # noqa: S108
dot_env_file = Path(__file__).parent.parent / "ui" / ".env"
env_config = {}
package_json = await read_package_json()
env_vars = {
"hyperglass": {"version": __version__},
"env": {},
}
# Set NextJS production/development mode and base URL based on
# developer_mode setting.
if dev_mode:
env_vars["env"].update({"NODE_ENV": "development"})
env_vars["hyperglass"].update({"url": dev_url})
env_config.update({"HYPERGLASS_URL": dev_url, "NODE_ENV": "development"})
else:
env_vars["env"].update({"NODE_ENV": "production"})
env_vars["hyperglass"].update({"url": prod_url})
env_config.update({"HYPERGLASS_URL": prod_url, "NODE_ENV": "production"})
# Check if hyperglass/ui/node_modules has been initialized. If not,
# initialize it.
@ -310,71 +312,70 @@ async def build_frontend( # noqa: C901
images_dir = app_path / "static" / "images"
favicon_dir = images_dir / "favicons"
try:
if not favicon_dir.exists():
favicon_dir.mkdir()
async with Favicons(
source=params.web.logo.favicon,
output_directory=favicon_dir,
base_url="/images/favicons/",
) as favicons:
await favicons.generate()
log.debug("Generated {} favicons", favicons.completed)
env_vars["hyperglass"].update({"favicons": favicons.formats()})
build_data = {
"params": params.export_dict(),
"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(build_json.encode()).hexdigest()
if not favicon_dir.exists():
favicon_dir.mkdir()
# 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: {}", ef_id)
async with Favicons(
source=params.web.logo.favicon,
output_directory=favicon_dir,
base_url="/images/favicons/",
) as favicons:
await favicons.generate()
log.debug("Generated {} favicons", favicons.completed)
write_favicon_formats(favicons.formats())
if ef_id == build_id:
log.debug("UI parameters unchanged since last build, skipping UI build...")
return True
build_data = {
"params": params.export_dict(),
"version": __version__,
"package_json": package_json,
}
env_vars["buildId"] = build_id
build_json = json.dumps(build_data, default=str)
env_file.write_text(json.dumps(env_vars, default=str))
log.debug("Wrote UI environment file '{}'", str(env_file))
# Create SHA256 hash from all parameters passed to UI, use as
# build identifier.
build_id = hashlib.sha256(build_json.encode()).hexdigest()
# Initiate Next.JS export process.
if any((not dev_mode, force, full)):
log.info("Starting UI build")
initialize_result = await node_initial(timeout, dev_mode)
build_result = await build_ui(app_path=app_path)
# Read hard-coded environment file from last build. If build ID
# matches this build's ID, don't run a new build.
if dot_env_file.exists() and not force:
env_data = dotenv_to_dict(dot_env_file)
env_build_id = env_data.get("HYPERGLASS_BUILD_ID", "None")
log.debug("Previous Build ID: {!r}", env_build_id)
if initialize_result:
log.debug(initialize_result)
elif initialize_result == "":
log.debug("Re-initialized node_modules")
if env_build_id == build_id:
log.debug("UI parameters unchanged since last build, skipping UI build...")
return True
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")
env_config.update({"HYPERGLASS_BUILD_ID": build_id})
migrate_images(app_path, params)
dot_env_file.write_text("\n".join(f"{k}={v}" for k, v in env_config.items()))
log.debug("Wrote UI environment file {!r}", str(dot_env_file))
generate_opengraph(
params.web.opengraph.image,
1200,
630,
images_dir,
params.web.theme.colors.black,
)
# Initiate Next.JS export process.
if any((not dev_mode, force, full)):
log.info("Starting UI build")
initialize_result = await node_initial(timeout, dev_mode)
build_result = await build_ui(app_path=app_path)
except Exception as err:
log.error(err)
raise RuntimeError(str(err)) from None
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")
migrate_images(app_path, params)
generate_opengraph(
params.web.opengraph.image,
1200,
630,
images_dir,
params.web.theme.colors.black,
)
return True

View file

@ -0,0 +1,50 @@
"""Test file-related utilities."""
# Standard Library
import tempfile
from pathlib import Path
# Third Party
import pytest
# Local
from ..files import dotenv_to_dict
ENV_TEST = """KEY1=VALUE1
KEY2=VALUE2
KEY3=VALUE3
"""
def test_dotenv_to_dict_string():
result = dotenv_to_dict(ENV_TEST)
assert result.get("KEY1") == "VALUE1"
assert result.get("KEY2") == "VALUE2"
assert result.get("KEY3") == "VALUE3"
def test_dotenv_to_dict_file():
_, filename = tempfile.mkstemp()
file = Path(filename)
with file.open("w+") as f:
f.write(ENV_TEST)
result = dotenv_to_dict(file)
assert result.get("KEY1") == "VALUE1"
assert result.get("KEY2") == "VALUE2"
assert result.get("KEY3") == "VALUE3"
file.unlink()
def test_dotenv_to_dict_raises_type_error():
with pytest.raises(TypeError):
dotenv_to_dict(True)
def test_dotenv_to_dict_raises_filenotfounderror():
with pytest.raises(FileNotFoundError):
dotenv_to_dict(Path("/tmp/not-a-thing")) # noqa: S108
def test_dotenv_invalid_format():
with pytest.raises(TypeError):
dotenv_to_dict("this should raise an error")