From d7aa55590bbc9f9eb4eb48578c6057b923b9339d Mon Sep 17 00:00:00 2001 From: Michel Zimmer Date: Wed, 31 Aug 2022 11:09:38 +0200 Subject: [PATCH] Switch from API host to path prefix - Remove configuration - Add integrated mock server --- .dockerignore | 2 + .env.development | 1 - Dockerfile | 4 +- docker-compose.yml | 7 +- index.html | 1 - mock-server.mjs | 47 ------------- mocks/apiHandler.ts | 105 +++++++++++++++++++++++++++++ stats.dot => mocks/stats.dot | 0 mocks/stats.json | 1 + nginx.conf | 12 +++- package.json | 5 +- src/Configuration.ts | 13 ---- src/Graph.tsx | 7 +- src/Header.tsx | 3 +- src/Main.tsx | 3 +- src/Stats.ts | 5 +- stats.json | 1 - tsconfig.node.json | 6 +- vite-plugin-serve-handler/index.ts | 52 ++++++++++++++ vite.config.ts | 17 +++-- 20 files changed, 201 insertions(+), 91 deletions(-) delete mode 100644 .env.development delete mode 100644 mock-server.mjs create mode 100644 mocks/apiHandler.ts rename stats.dot => mocks/stats.dot (100%) create mode 100644 mocks/stats.json delete mode 100644 src/Configuration.ts delete mode 100644 stats.json create mode 100644 vite-plugin-serve-handler/index.ts diff --git a/.dockerignore b/.dockerignore index 63aeb86..eb963c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,12 @@ **/* !index.html +!mocks/ !nginx.conf !package.json !public/ !src/ !tsconfig.json !tsconfig.node.json +!vite-plugin-serve-handler/ !vite.config.ts !yarn.lock \ No newline at end of file diff --git a/.env.development b/.env.development deleted file mode 100644 index 4b00efa..0000000 --- a/.env.development +++ /dev/null @@ -1 +0,0 @@ -BANDWHICHD_API_SERVER=http://localhost:8080 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f9f5cb5..0557990 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,10 @@ COPY --chown=node:node \ tsconfig.node.json \ vite.config.ts \ ./ +COPY --chown=node:node mocks ./mocks COPY --chown=node:node public ./public COPY --chown=node:node src ./src +COPY --chown=node:node vite-plugin-serve-handler ./vite-plugin-serve-handler RUN yarn build FROM nginxinc/nginx-unprivileged:alpine COPY nginx.conf /etc/nginx/templates/default.conf.template @@ -24,5 +26,5 @@ LABEL org.opencontainers.image.vendor="neuland – Büro für Informatik GmbH" LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.title="bandwhichd-ui" LABEL org.opencontainers.image.description="bandwhichd ui displaying network topology and statistics" -LABEL org.opencontainers.image.version="0.3.0" +LABEL org.opencontainers.image.version="0.4.0" COPY --from=build --chown=root:root /home/node/dist /usr/share/nginx/html \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2a14e43..dda5d9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,6 @@ services: main: build: context: . - env_file: - - .env.development - ports: - - 3000:8080 \ No newline at end of file + environment: + BANDWHICHD_API_SERVER: http://127.0.0.1:8080 + network_mode: host \ No newline at end of file diff --git a/index.html b/index.html index ff514ae..7a2bdd4 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,6 @@ bandwhichd-ui - diff --git a/mock-server.mjs b/mock-server.mjs deleted file mode 100644 index a00a881..0000000 --- a/mock-server.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import fs from "fs" -import http from "http" -import path from "path" -import process from "process" - -const port = 8080 - -const server = http.createServer((request, response) => { - const chunks = [] - - request.on("data", chunk => { - chunks.push(chunk) - }) - - request.on("end", () => { - if (request.method !== "GET" - || request.url !== "/v1/stats") { - response.writeHead(404) - response.end() - return - } - - if (chunks.length > 0) { - response.writeHead(400) - response.end() - return - } - - const format = - request.headers.accept === "text/vnd.graphviz; q=1.0" - ? "dot" - : "json" - - const filePath = path.join(process.cwd(), `stats.${format}`) - const fileStat = fs.statSync(filePath) - - response.writeHead(200, { - "Access-Control-Allow-Origin": "*", - "Content-Length": fileStat.size - }) - fs.createReadStream(filePath).pipe(response) - }) -}) - -server.listen(port, () => { - console.log(`bandwhichd mock server listening on port ${port}`) -}) \ No newline at end of file diff --git a/mocks/apiHandler.ts b/mocks/apiHandler.ts new file mode 100644 index 0000000..b766840 --- /dev/null +++ b/mocks/apiHandler.ts @@ -0,0 +1,105 @@ +import fs from "fs" +import http from "http"; +import path from "path" +import process from "process" +import type { Connect, ResolvedConfig } from "vite"; + +import { ServeHandler } from "../vite-plugin-serve-handler"; + +const isApiRoute: (url: string) => boolean = + (url) => url === "/api" || url.startsWith("/api?") || url.startsWith("/api/"); + +const bandwhichdApiServerFromEnv: (viteConfig: ResolvedConfig) => URL | null = + (viteConfig) => { + const bandwhichdApiServer = viteConfig.env["BANDWHICHD_API_SERVER"]; + try { + return new URL(bandwhichdApiServer); + } catch (_) { + return null; + } + }; + +const handleWithMocks = + (request: Connect.IncomingMessage, response: http.ServerResponse) => { + const chunks = []; + request.on("data", chunk => { + chunks.push(chunk); + }); + + request.on("end", () => { + if (request.method !== "GET" + || request.url !== "/api/v1/stats") { + response.writeHead(404); + response.end(); + return; + } + + if (chunks.length > 0) { + response.writeHead(400); + response.end(); + return; + } + + const format = + request.headers.accept === "text/vnd.graphviz; q=1.0" + ? "dot" + : "json"; + + const filePath = path.join(process.cwd(), 'mocks', `stats.${format}`); + const fileStat = fs.statSync(filePath); + + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Content-Length": fileStat.size, + }); + fs.createReadStream(filePath).pipe(response); + }); + }; + +const handleWithServer = + (request: Connect.IncomingMessage, response: http.ServerResponse, viteConfig: ResolvedConfig, bandwhichdApiServer: URL) => { + const upstreamRequestOptions: http.RequestOptions = { + protocol: bandwhichdApiServer.protocol, + host: bandwhichdApiServer.hostname, + port: bandwhichdApiServer.port, + method: request.method, + path: request.url.substring("/api".length), + headers: { + ...request.headers, + host: `${bandwhichdApiServer.host}`, + }, + }; + + request.pipe(http.request(upstreamRequestOptions, (upstreamResponse) => { + upstreamResponse.pipe(response).on("error", (error) => { + viteConfig.logger.error("mocks/apiHandler: Error proxying response", { + error, + timestamp: true + }); + response.end(); + }); + })).on("error", (error) => { + viteConfig.logger.error("mocks/apiHandler: Error proxying request", { + error, + timestamp: true + }); + response.end(); + }); + }; + +export const apiHandler: ServeHandler.Handler = + (request, response, viteConfig) => { + if (!isApiRoute(request.url)) { + return false; + } + + const bandwhichdApiServer = bandwhichdApiServerFromEnv(viteConfig); + + if (bandwhichdApiServer === null) { + handleWithMocks(request, response); + } else { + handleWithServer(request, response, viteConfig, bandwhichdApiServer); + } + + return true; + }; \ No newline at end of file diff --git a/stats.dot b/mocks/stats.dot similarity index 100% rename from stats.dot rename to mocks/stats.dot diff --git a/mocks/stats.json b/mocks/stats.json new file mode 100644 index 0000000..9754ac6 --- /dev/null +++ b/mocks/stats.json @@ -0,0 +1 @@ +{"hosts":{"28e9f9f4-c9ec-7448-d591-f8fce34086ce":{"hostname":"spring-db1","additional_hostnames":[],"connections":{}},"d416fa7d-edef-ded2-5074-3244110a5a3d":{"hostname":"spring-mongodb","additional_hostnames":[],"connections":{}},"fda7ecc9-eb5f-002d-2d11-eb4ab88ef9e4":{"hostname":"spring-logging","additional_hostnames":[],"connections":{}},"b110d228-d4c9-667b-c4da-913425b25175":{"hostname":"ecom-nginx-services","additional_hostnames":[],"connections":{}},"3509c6bc-b535-a72a-2bc1-f5b585e03063":{"hostname":"ecom-cache2","additional_hostnames":[],"connections":{}},"dda70a41-0ee2-8e2c-dc15-1a12cd46b7e4":{"hostname":"ecom-app-live1","additional_hostnames":[],"connections":{}},"f6541cc6-8eea-8e2c-d40b-959e18660923":{"hostname":"ecom-cache","additional_hostnames":[],"connections":{}},"55faca59-16af-34b8-9f0d-882b6531e82d":{"hostname":"spring-staging","additional_hostnames":[],"connections":{}},"2b09a772-335d-d36b-1cfb-eb75f6c9bea1":{"hostname":"spring-app-live2","additional_hostnames":[],"connections":{}},"fd4760d6-baa4-3b27-3bf0-a83174eb5014":{"hostname":"spring-web-live6","additional_hostnames":[],"connections":{}},"35b2b94f-41f9-c120-8852-af0665d5b628":{"hostname":"spring-cache","additional_hostnames":[],"connections":{}},"6075cc26-5b1b-5f01-f0d8-2a78e139a0d2":{"hostname":"spring-cache2","additional_hostnames":[],"connections":{}},"1c9684d1-cb83-501e-63da-9077e9868a98":{"hostname":"spring-services1","additional_hostnames":[],"connections":{}},"2000044d-80c2-fb2f-80a7-30345cea90ff":{"hostname":"ecom-services1","additional_hostnames":[],"connections":{}},"eebbdbb0-1fdc-7802-8b35-6a5f84699eb5":{"hostname":"ecom-services2","additional_hostnames":[],"connections":{}},"045c74d2-e4f6-6c5d-76fa-a55a3b23be6e":{"hostname":"ecom-logging","additional_hostnames":[],"connections":{}},"0ead0515-5d04-9c5a-9a8d-9724d34882ee":{"hostname":"ecom-app-live2","additional_hostnames":[],"connections":{}},"78334682-0244-2adb-6cf2-c243717a3f58":{"hostname":"ecom-web-live3","additional_hostnames":[],"connections":{}},"71d66f34-2bc1-7853-bc9d-0ee87a963264":{"hostname":"ecom-web-live6","additional_hostnames":[],"connections":{}},"a5a0f816-8cb2-a634-9dc5-6d28dbbef6e3":{"hostname":"spring-staging2","additional_hostnames":[],"connections":{}},"40b13e86-8d85-816d-bbe4-09d223eaf94c":{"hostname":"spring-nginx-services","additional_hostnames":[],"connections":{}},"0add42fd-d09b-13ad-62d0-9d7edf87e7b1":{"hostname":"spring-app-live1","additional_hostnames":[],"connections":{}},"98bc86ae-6dd9-05a0-4119-851e82b84dc2":{"hostname":"spring-services2","additional_hostnames":[],"connections":{}},"2c22b189-ed70-9ad1-b758-54bd5b1aef4b":{"hostname":"spring-web-live3","additional_hostnames":[],"connections":{}}},"unmonitoredHosts":{}} \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index cb0a87f..749aa62 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,10 +1,16 @@ server { - listen 8080; - listen [::]:8080; + listen 3000; + listen [::]:3000; + + location ~ ^/api(/.*)?$ { + proxy_pass ${BANDWHICHD_API_SERVER}$1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + } location / { root /usr/share/nginx/html; index index.html; - sub_filter 'PLACEHOLDER_API_SERVER' '${BANDWHICHD_API_SERVER}'; } } \ No newline at end of file diff --git a/package.json b/package.json index 1674f66..e8667aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bandwhichd-ui", - "version": "0.3.0", + "version": "0.4.0", "description": "bandwhichd ui displaying network topology and statistics", "license": "MIT", "private": true, @@ -9,8 +9,7 @@ "dev": "vite --port=3000", "build": "tsc && vite build", "preview": "vite preview", - "test": "jest", - "mock-server": "node mock-server.mjs" + "test": "jest" }, "dependencies": { "fp-ts": "^2.12.2", diff --git a/src/Configuration.ts b/src/Configuration.ts deleted file mode 100644 index 9840f46..0000000 --- a/src/Configuration.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface Configuration { - apiServer: string; -} - -if (import.meta.env.DEV) { - // @ts-ignore - document.configuration = { - apiServer: import.meta.env.BANDWHICHD_API_SERVER, - }; -} - -// @ts-ignore -export const configuration: Configuration = document.configuration \ No newline at end of file diff --git a/src/Graph.tsx b/src/Graph.tsx index df4c815..ddc114d 100644 --- a/src/Graph.tsx +++ b/src/Graph.tsx @@ -1,12 +1,11 @@ import React, { useEffect, useRef, useState } from "react"; import * as VisNetwork from "vis-network"; -import { Configuration, configuration } from "./Configuration"; import { HostId } from "./Stats"; import styles from "./Graph.module.css"; -const fetchData = async (configuration: Configuration): Promise => { - const response = await window.fetch(`${configuration.apiServer}/v1/stats`, { +const fetchData = async (): Promise => { + const response = await window.fetch("/api/v1/stats", { method: "GET", headers: { "Accept": "text/vnd.graphviz; q=1.0" @@ -33,7 +32,7 @@ export const Graph: React.FC = const container = containerRef.current; setIsLoading(true); - fetchData(configuration).then(data => { + fetchData().then(data => { // @ts-ignore const parsedData = VisNetwork.parseDOTNetwork(data); parsedData.options.physics = { diff --git a/src/Header.tsx b/src/Header.tsx index eb1d420..2f0a7a4 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -1,7 +1,6 @@ import React from "react"; -import { configuration } from "./Configuration"; import styles from "./Header.module.css" export const Header: React.FC = - () =>
Connected to {configuration.apiServer}
; \ No newline at end of file + () =>
Timeframe: 2 hours
; \ No newline at end of file diff --git a/src/Main.tsx b/src/Main.tsx index b106d0d..22b8d20 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from "react"; -import { configuration } from "./Configuration"; import { Graph } from "./Graph"; import { HostDetails } from "./HostDetails"; @@ -23,7 +22,7 @@ export const Main: React.FC = : { hostId: maybeSelectedHostId, ...maybeSelectedHostWithoutId }; useEffect(() => { - fetchStats(configuration).then(stats => { + fetchStats().then(stats => { setMaybeStats(stats); }).catch(console.error); }, []); diff --git a/src/Stats.ts b/src/Stats.ts index 6f3049d..bb89da8 100644 --- a/src/Stats.ts +++ b/src/Stats.ts @@ -1,4 +1,3 @@ -import { Configuration } from "./Configuration"; import { Map } from "immutable"; import * as Decoder from "io-ts/lib/Decoder" import { mapDecoder } from "./lib/immutable/io-ts/mapDecoder"; @@ -45,8 +44,8 @@ const statsDecoder = Decoder.struct({ }); export const fetchStats = - async (configuration: Configuration): Promise => - await window.fetch(`${configuration.apiServer}/v1/stats`, { + async (): Promise => + await window.fetch("/api/v1/stats", { method: "GET", headers: { "Accept": "application/json; q=1.0" diff --git a/stats.json b/stats.json deleted file mode 100644 index 505ac36..0000000 --- a/stats.json +++ /dev/null @@ -1 +0,0 @@ -{"hosts":{"28e9f9f4-c9ec-7448-d591-f8fce34086ce":{"hostname":"spring-db1","additional_hostnames":[]},"d416fa7d-edef-ded2-5074-3244110a5a3d":{"hostname":"spring-mongodb","additional_hostnames":[]},"fda7ecc9-eb5f-002d-2d11-eb4ab88ef9e4":{"hostname":"spring-logging","additional_hostnames":[]},"b110d228-d4c9-667b-c4da-913425b25175":{"hostname":"ecom-nginx-services","additional_hostnames":[]},"3509c6bc-b535-a72a-2bc1-f5b585e03063":{"hostname":"ecom-cache2","additional_hostnames":[]},"dda70a41-0ee2-8e2c-dc15-1a12cd46b7e4":{"hostname":"ecom-app-live1","additional_hostnames":[]},"f6541cc6-8eea-8e2c-d40b-959e18660923":{"hostname":"ecom-cache","additional_hostnames":[]},"55faca59-16af-34b8-9f0d-882b6531e82d":{"hostname":"spring-staging","additional_hostnames":[]},"2b09a772-335d-d36b-1cfb-eb75f6c9bea1":{"hostname":"spring-app-live2","additional_hostnames":[]},"fd4760d6-baa4-3b27-3bf0-a83174eb5014":{"hostname":"spring-web-live6","additional_hostnames":[]},"35b2b94f-41f9-c120-8852-af0665d5b628":{"hostname":"spring-cache","additional_hostnames":[]},"6075cc26-5b1b-5f01-f0d8-2a78e139a0d2":{"hostname":"spring-cache2","additional_hostnames":[]},"1c9684d1-cb83-501e-63da-9077e9868a98":{"hostname":"spring-services1","additional_hostnames":[]},"2000044d-80c2-fb2f-80a7-30345cea90ff":{"hostname":"ecom-services1","additional_hostnames":[]},"eebbdbb0-1fdc-7802-8b35-6a5f84699eb5":{"hostname":"ecom-services2","additional_hostnames":[]},"045c74d2-e4f6-6c5d-76fa-a55a3b23be6e":{"hostname":"ecom-logging","additional_hostnames":[]},"0ead0515-5d04-9c5a-9a8d-9724d34882ee":{"hostname":"ecom-app-live2","additional_hostnames":[]},"78334682-0244-2adb-6cf2-c243717a3f58":{"hostname":"ecom-web-live3","additional_hostnames":[]},"71d66f34-2bc1-7853-bc9d-0ee87a963264":{"hostname":"ecom-web-live6","additional_hostnames":[]},"a5a0f816-8cb2-a634-9dc5-6d28dbbef6e3":{"hostname":"spring-staging2","additional_hostnames":[]},"40b13e86-8d85-816d-bbe4-09d223eaf94c":{"hostname":"spring-nginx-services","additional_hostnames":[]},"0add42fd-d09b-13ad-62d0-9d7edf87e7b1":{"hostname":"spring-app-live1","additional_hostnames":[]},"98bc86ae-6dd9-05a0-4119-851e82b84dc2":{"hostname":"spring-services2","additional_hostnames":[]},"2c22b189-ed70-9ad1-b758-54bd5b1aef4b":{"hostname":"spring-web-live3","additional_hostnames":[]}}} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json index 9d31e2a..da0fbcd 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,5 +5,9 @@ "moduleResolution": "Node", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": [ + "mocks/", + "vite.config.ts", + "vite-plugin-serve-handler/", + ] } diff --git a/vite-plugin-serve-handler/index.ts b/vite-plugin-serve-handler/index.ts new file mode 100644 index 0000000..877ff96 --- /dev/null +++ b/vite-plugin-serve-handler/index.ts @@ -0,0 +1,52 @@ +import http from "http"; +import type { Connect, Plugin, ResolvedConfig, ViteDevServer } from "vite"; + +export namespace ServeHandler { + export type Handler = (request: Connect.IncomingMessage, response: http.ServerResponse, viteConfig: ResolvedConfig) => boolean; +} + +const serverHandlerMiddleware: (config: ResolvedConfig, options: FinalServerHandlerOptions) => Connect.NextHandleFunction = + (config, options) => (request, response, next) => { + try { + if (!options.handler(request, response, config)) { + next(); + } + } catch (error) { + config.logger.error("vite-plugin-serve-handler: handler error", { + error, + timestamp: true + }); + next(); + } + }; + +export interface ServeHandlerOptions { + handler?: ServeHandler.Handler; +} + +interface FinalServerHandlerOptions { + handler: ServeHandler.Handler; +} + +const defaultServeHandlerOptions: FinalServerHandlerOptions = { + handler: (_, __) => false, +}; + +export const ServeHandler: (options?: ServeHandlerOptions) => Plugin = + (givenOptions) => { + let config: ResolvedConfig | null = null; + const options: FinalServerHandlerOptions = + typeof givenOptions !== "object" + ? defaultServeHandlerOptions + : { ...defaultServeHandlerOptions, ...givenOptions }; + return { + name: "vite-plugin-serve-handler", + apply: "serve", + configResolved: (resolvedConfig) => { + config = resolvedConfig; + }, + configureServer: (server: ViteDevServer) => { + server.middlewares.use(serverHandlerMiddleware(config, options)); + }, + }; + }; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index e02e6a7..6fe3dc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,15 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { ServeHandler } from "./vite-plugin-serve-handler"; +import { apiHandler } from "./mocks/apiHandler"; // https://vitejs.dev/config/ export default defineConfig({ - envPrefix: 'BANDWHICHD_', - plugins: [react()] -}) \ No newline at end of file + envPrefix: "BANDWHICHD_", + plugins: [ + react(), + ServeHandler({ + handler: apiHandler, + }), + ] +}); \ No newline at end of file