diff --git a/docs/docs/parameters.mdx b/docs/docs/parameters.mdx index c3c07aa..834e555 100644 --- a/docs/docs/parameters.mdx +++ b/docs/docs/parameters.mdx @@ -54,6 +54,7 @@ The following global settings can be set in `hyperglass.yaml`: | `listen_port` | Integer | `8001` | Local TCP port the hyperglass application listens on to serve web traffic. | | `cors_origins` | List | `[]` | Allowed [CORS](https://developer.mozilla.org/docs/Web/HTTP/CORS) hosts. By default, no CORS hosts are allowed. | | `netmiko_delay_factor` | Integer \| Float | `0.1` | Override the [Netmiko global delay factor](https://ktbyers.github.io/netmiko/docs/netmiko/index.html). | +| `google_analytics` | String | | Google Analytics Tracking ID | :::note The `netmiko_delay_factor` parameter should only be used if you're experiencing strange SSH connection issues. By default, Netmiko uses a `global_delay_factor` of `1`, which tends to be a bit slow for running a simple show command. hyperglass overrides this to `0.1` by default, but you can override this to whatever value suits your environment if needed. diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py index c6f725b..dc35064 100644 --- a/hyperglass/models/config/params.py +++ b/hyperglass/models/config/params.py @@ -115,6 +115,7 @@ class Params(HyperglassModel): title="Netmiko Delay Factor", description="Override the netmiko global delay factor.", ) + google_analytics: Optional[StrictStr] # Sub Level Params cache: Cache = Cache() diff --git a/hyperglass/ui/hooks/index.ts b/hyperglass/ui/hooks/index.ts index a82290f..f5e3bc6 100644 --- a/hyperglass/ui/hooks/index.ts +++ b/hyperglass/ui/hooks/index.ts @@ -2,6 +2,7 @@ export * from './useASNDetail'; export * from './useBooleanValue'; export * from './useDevice'; export * from './useDNSQuery'; +export * from './useGoogleAnalytics'; export * from './useGreeting'; export * from './useLGQuery'; export * from './useLGState'; diff --git a/hyperglass/ui/hooks/types.ts b/hyperglass/ui/hooks/types.ts index 853319d..39d6934 100644 --- a/hyperglass/ui/hooks/types.ts +++ b/hyperglass/ui/hooks/types.ts @@ -1,5 +1,6 @@ -import { State } from '@hookstate/core'; +import type { State } from '@hookstate/core'; import type { QueryFunctionContext } from 'react-query'; +import type * as ReactGA from 'react-ga'; import type { TDevice, Families, @@ -89,9 +90,14 @@ export type TLGStateHandlers = { stateExporter(o: O): O | null; }; -export type UseStrfArgs = { [k: string]: any } | string; +export type UseStrfArgs = { [k: string]: unknown } | string; -export type TTableToStringFormatter = (v: any) => string; +export type TTableToStringFormatter = + | ((v: string) => string) + | ((v: number) => string) + | ((v: number[]) => string) + | ((v: string[]) => string) + | ((v: boolean) => string); export type TTableToStringFormatted = { age: (v: number) => string; @@ -100,3 +106,13 @@ export type TTableToStringFormatted = { communities: (v: string[]) => string; rpki_state: (v: number, n: TRPKIStates) => string; }; + +export type GAEffect = (ga: typeof ReactGA) => void; + +export interface GAReturn { + ga: typeof ReactGA; + initialize(trackingId: string | null, debug: boolean): void; + trackPage(path: string): void; + trackModal(path: string): void; + trackEvent(event: ReactGA.EventArgs): void; +} diff --git a/hyperglass/ui/hooks/useDNSQuery.ts b/hyperglass/ui/hooks/useDNSQuery.ts index 3178181..fcc34c8 100644 --- a/hyperglass/ui/hooks/useDNSQuery.ts +++ b/hyperglass/ui/hooks/useDNSQuery.ts @@ -1,6 +1,7 @@ import { useQuery } from 'react-query'; import { useConfig } from '~/context'; import { fetchWithTimeout } from '~/util'; +import { useGoogleAnalytics } from './useGoogleAnalytics'; import type { QueryObserverResult } from 'react-query'; import type { DnsOverHttps } from '~/types'; @@ -49,6 +50,12 @@ export function useDNSQuery( family: 4 | 6, ): QueryObserverResult { const { cache, web } = useConfig(); + const { trackEvent } = useGoogleAnalytics(); + + if (typeof target === 'string') { + trackEvent({ category: 'DNS', action: 'Query', label: target, dimension1: `IPv${family}` }); + } + return useQuery([web.dns_provider.url, { target, family }], dnsQuery, { cacheTime: cache.timeout * 1000, }); diff --git a/hyperglass/ui/hooks/useGoogleAnalytics.tsx b/hyperglass/ui/hooks/useGoogleAnalytics.tsx new file mode 100644 index 0000000..61659d1 --- /dev/null +++ b/hyperglass/ui/hooks/useGoogleAnalytics.tsx @@ -0,0 +1,81 @@ +import { useCallback } from 'react'; +import { createState, useState } from '@hookstate/core'; +import * as ReactGA from 'react-ga'; + +import type { GAEffect, GAReturn } from './types'; + +const enabledState = createState(false); + +export function useGoogleAnalytics(): GAReturn { + const enabled = useState(enabledState); + + const useAnalytics = useCallback((effect: GAEffect): void => { + if (typeof window !== 'undefined' && enabled.value) { + if (typeof effect === 'function') { + effect(ReactGA); + } + } + }, []); + + const trackEvent = useCallback((e: ReactGA.EventArgs) => { + useAnalytics(ga => { + if (process.env.NODE_ENV === 'production') { + ga.event(e); + } else { + console.log( + `%cEvent %c${JSON.stringify(e)}`, + 'background: green; color: black; padding: 0.5rem; font-size: 0.75rem;', + 'background: black; color: green; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;', + ); + } + }); + }, []); + + const trackPage = useCallback((path: string) => { + useAnalytics(ga => { + if (process.env.NODE_ENV === 'production') { + ga.pageview(path); + } else { + console.log( + `%cPage View %c${path}`, + 'background: blue; color: white; padding: 0.5rem; font-size: 0.75rem;', + 'background: white; color: blue; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;', + ); + } + }); + }, []); + + const trackModal = useCallback((path: string) => { + useAnalytics(ga => { + if (process.env.NODE_ENV === 'production') { + ga.modalview(path); + } else { + console.log( + `%cModal View %c${path}`, + 'background: red; color: white; padding: 0.5rem; font-size: 0.75rem;', + 'background: white; color: red; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;', + ); + } + }); + }, []); + + const initialize = useCallback((trackingId: string, debug: boolean) => { + if (typeof trackingId !== 'string') { + return; + } + + enabled.set(true); + + const initializeOpts = { titleCase: false } as ReactGA.InitializeOptions; + + if (debug) { + initializeOpts.debug = true; + } + + useAnalytics(ga => { + ga.initialize(trackingId, initializeOpts); + }); + }, []); + + return { trackEvent, trackModal, trackPage, initialize, ga: ReactGA }; +} diff --git a/hyperglass/ui/hooks/useLGQuery.ts b/hyperglass/ui/hooks/useLGQuery.ts index dfd73a1..30d55f5 100644 --- a/hyperglass/ui/hooks/useLGQuery.ts +++ b/hyperglass/ui/hooks/useLGQuery.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useQuery } from 'react-query'; import { useConfig } from '~/context'; +import { useGoogleAnalytics } from './useGoogleAnalytics'; import { fetchWithTimeout } from '~/util'; import type { QueryObserverResult } from 'react-query'; @@ -14,6 +15,17 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult { const [url, data] = ctx.queryKey; const { queryLocation, queryTarget, queryType, queryVrf } = data; diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index 4c07821..4f7b74f 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -6,11 +6,11 @@ "license": "BSD-3-Clause-Clear", "private": true, "scripts": { - "lint": "eslint .", + "lint": "eslint . --ext .ts --ext .tsx", "dev": "node nextdev", "start": "next start", "typecheck": "tsc --noEmit", - "format": "prettier -c -w .", + "format": "prettier -c .", "clean": "rimraf --no-glob ./.next ./out", "check:es:export": "es-check es5 './out/**/*.js' -v", "check:es:build": "es-check es5 './.next/static/**/*.js' -v", @@ -22,7 +22,7 @@ "@emotion/react": "^11.1.4", "@emotion/styled": "^11.0.0", "@hookform/resolvers": "^1.2.0", - "@hookstate/core": "^3.0.1", + "@hookstate/core": "^3.0.3", "@hookstate/persistence": "^3.0.0", "@meronex/icons": "^4.0.0", "color2k": "^1.1.1", @@ -36,6 +36,7 @@ "react-dom": "^17.0.1", "react-fast-compare": "^3.2.0", "react-flow-renderer": "^8.2.3", + "react-ga": "^3.3.0", "react-hook-form": "^6.13.1", "react-markdown": "^5.0.3", "react-query": "^3.5.6", diff --git a/hyperglass/ui/pages/_app.tsx b/hyperglass/ui/pages/_app.tsx index fc9ca7f..1377a83 100644 --- a/hyperglass/ui/pages/_app.tsx +++ b/hyperglass/ui/pages/_app.tsx @@ -1,5 +1,7 @@ +import { useEffect } from 'react'; import Head from 'next/head'; import { HyperglassProvider } from '~/context'; +import { useGoogleAnalytics } from '~/hooks'; import { IConfig } from '~/types'; import type { AppProps, AppInitialProps, AppContext } from 'next/app'; @@ -12,13 +14,20 @@ type TApp = { config: IConfig }; type GetInitialPropsReturn = AppProps & AppInitialProps & { appProps: IP }; -type Temp = React.FC> & { +type NextApp = React.FC> & { getInitialProps(c?: AppContext): Promise<{ appProps: IP }>; }; -const App: Temp = (props: GetInitialPropsReturn) => { - const { Component, pageProps, appProps } = props; +const App: NextApp = (props: GetInitialPropsReturn) => { + 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); + }, []); return ( <> diff --git a/hyperglass/ui/types/config.ts b/hyperglass/ui/types/config.ts index 38493bf..af8caee 100644 --- a/hyperglass/ui/types/config.ts +++ b/hyperglass/ui/types/config.ts @@ -169,7 +169,7 @@ export interface IConfig { primary_asn: string; request_timeout: number; org_name: string; - google_analytics?: string; + google_analytics: string | null; site_title: string; site_keywords: string[]; site_description: string; diff --git a/hyperglass/ui/yarn.lock b/hyperglass/ui/yarn.lock index bf3d44b..4f5e115 100644 --- a/hyperglass/ui/yarn.lock +++ b/hyperglass/ui/yarn.lock @@ -926,10 +926,10 @@ resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-1.2.0.tgz#3a429b4bd3ec9981764fcdcc2491202f9f72d847" integrity sha512-YCKEj/3Kdo3uNt+zrWKV8txaiuATtvgHyz+KYmun3n5JDjxdI0HcVQgfcmJabmkBXBzKuIIrYfxaV8sRuAPZ8w== -"@hookstate/core@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@hookstate/core/-/core-3.0.1.tgz#dc46ea71e3bf0ab5c2dc024029c9210ed12fbb84" - integrity sha512-buRie83l3FYPLCuaBE68puE3XS19r2O+jwC/kH2ikIW7ww8AavndR3MspzMMkNpq2zL8pZtIcpiWJBbn4Uq2Vw== +"@hookstate/core@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@hookstate/core/-/core-3.0.3.tgz#9fdd0e29d3cd4f3ce70ec209071d9d31b3a33677" + integrity sha512-tlcBxWrOZCEw3ExHTlvLK93q+JPV6d1BayjvFkXIPDCNC0fSc/aXOWjE7gywTnUYrrsidYNiOBjAROHUIWHoUw== "@hookstate/devtools@^3.0.0": version "3.0.0" @@ -6189,6 +6189,11 @@ react-focus-lock@2.4.1: use-callback-ref "^1.2.1" use-sidecar "^1.0.1" +react-ga@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.3.0.tgz#c91f407198adcb3b49e2bc5c12b3fe460039b3ca" + integrity sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ== + react-hook-form@^6.13.1: version "6.13.1" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.13.1.tgz#b9c0aa61f746db8169ed5e1050de21cacb1947d6"