Skip to content

Commit

Permalink
Make it possible to change configuration variables at runtime (#536)
Browse files Browse the repository at this point in the history
* Add runtime config in JSON format

* Add global runtime config from public folder

* Rework production docker image for runtime config

This updates the docker production image to be a fully operational nginx
server which serves the statically built Argus front-end.  Now that
runtime configuration is supported, the updated image will produce a
runtime config file from the same environment variables that were
originally expected by the frontend build process, and ensure it's in
the correct location before starting nginx.

* Fix proper update of backend url in axios

* Add Config class

For handling of fixed values, required and optional values and easy stripping of unknown config values provided by users

* Add handling of missing config file in dev environment

in DEV env: config file can still be added to overwrite defaults but is not required anymore

* Delete runtime-config file

It is no longer required in dev

* Update changelogs

* Document new configuration options

* Update docker/README.md

The image runtime has significantly changed, simplifying deployment.

* Update README.md

Co-authored-by: Morten Brekkevold <[email protected]>

* Document example runtime-config.json

* Specify config uri for prod env

* Add info about config in prod via env variables

* Polish runtime-config-template.json

---------

Co-authored-by: Morten Brekkevold <[email protected]>
  • Loading branch information
podliashanyk and lunkwill42 authored Apr 9, 2024
1 parent 62231b1 commit 8570175
Show file tree
Hide file tree
Showing 22 changed files with 368 additions and 167 deletions.
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules
build
.dockerignore
Dockerfile
docker/Dockerfile
docker/README.md
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ This file documents all changes to Argus-frontend. This file is primarily meant

## [Unreleased]

### Changed

- Configuration values can now be changed at runtime in development and production environments. See the docs for info about how to properly configure the frontend application in production.




## [v1.12.0] - 2023-09-28

Expand Down
85 changes: 83 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,41 @@ Note that the website will automatically reload as you edit the code.

### Configuration

Configuration options for the Argus frontend are located in `src/config.tsx`. All of these options can be set using environment variables when running the frontend under the Node server (or in Docker Compose). However, for production deployment, you would normally build the application and serve all the resulting static files using a regular web server, like Apache or Nginx; in this case, `config.tsx` cannot read server environment varibles, and should be hard coded instead.
Default configuration options for the Argus frontend are located in `src/config.tsx`. All of these options can be set using **environment** variables when running the frontend under the Node server (or in Docker Compose), or by providing **configuration** variables in `runtime-config.json` file.

**Configuration** variables can be provided by adding a `public/runtime-config.json` file when in development environment, or by serving a `/runtime-config.json` file when in production environment. Example configuration file looks like this:

```json
{
"backendUrl": "http://localhost:8000",
"enableWebsocketSupport": true,
"backendWSUrl": "ws://localhost:8000/ws",
"useSecureCookie": true,
"debug": true,
"cookieDomain": "localhost",
"defaultAutoRefreshInterval": 40,
"realtimeServiceMaxRetries": 7,
"use24hTime": true,
"timestampDateFormat": "yyyy-MM-dd",
"timestampTimeFormat": "HH:mm:ss",
"timestampTimeNoSeconds": "HH:mm",
"timestampTimezoneOffsetFormat": "xxx",
"timestampFormat": "{date} {time}{timezone_offset}",
"showSeverityLevels": true
}

These environment variables are available:
```

#### Development environment
Either provide **environment** variables when running the frontend under the Node server (or in Docker Compose), or add `runtime-config.json` file with the **configuration** variables to the `/public` folder.

#### Production environment
Serve `runtime-config.json` file with the **configuration** variables. It must be accessible as `%YOUR_FRONTEND_BASE_URL%/runtime-config.json`.

Alternatively, you can configure the application via **environment** variables if using the production-oriented Docker image defined in `./docker/Dockerfile`. This image will automatically produce a `runtime-config.json` from the environment variables exported to the container, as well as other variables provided in the `./docker/runtime-config-template.json`. Read more in the [docker/README.md](docker/README.md).

#### Variables
These **environment** variables are available:

<dl>
<dt>REACT_APP_BACKEND_URL</dt>
Expand All @@ -114,6 +146,55 @@ These environment variables are optional:
<dd>Ignore it if Argus frontend and backend are deployed on the same domain. Otherwise, set it to the same value as <code>ARGUS_COOKIE_DOMAIN</code> variable on the backend.</dd>
</dl>

**Configuration** variables can be provided in `runtime-config.json` file and will take precedence over the **environment** variables. These **configuration** variables are available:

<dl>
<dt>backendUrl</dt>
<dd>Format: string. The base URL to the Argus API server. MUST be provided in production environment, optional otherwise. </dd>

<dt>cookieDomain</dt>
<dd>Format: string. MUST be provided in production environment, optional otherwise.</dd>

<dt>enableWebsocketSupport</dt>
<dd>Format: boolean. Set to <code>true</code> to enable subscriptions to realtime incident updates.</dd>

<dt>backendWSUrl</dt>
<dd>Format: string. If you enable websocket support, this must be set to the backend's websocket URL. This value may depend on whether your deployment splits the HTTP server and the Web Socket servers into two components. Typically, if the backend HTTP server is <code>https://argus-api.example.org/</code>, this value could be <code>wss://argus-api.example.org/ws</code>.</dd>

<dt>realtimeServiceMaxRetries</dt>
<dd>Format: integer. If you enable websocket support, and it fails, this specifies how many times the application will retry connection before closing the socket.</dd>

<dt>defaultAutoRefreshInterval</dt>
<dd>Format: integer. Set to the default number of seconds between each auto refresh.</dd>

<dt>debug</dt>
<dd>Format: boolean. Set to <code>true</code> if you want debug output from the application.</dd>

<dt>showSeverityLevels/dt>
<dd>Format: boolean. Set to <code>true</code> if you want to enable filtering of incidents by severity levels.</dd>

<dt>useSecureCookie</dt>
<dd>Format: boolean. Set explicitly to <code>false</code> to disable the use of secure cookies. Typically only useful when deploying the development environment using non-TLS servers/regular HTTP.</dd>

<dt>timestampFormat</dt>
<dd>Format: string (valid ISO timestamp format). Specifies how a complete timestamp should be displayed.</dd>

<dt>timestampDateFormat</dt>
<dd>Format: string (valid ISO timestamp format). Specifies how dates should be displayed.</dd>

<dt>timestampTimeFormat</dt>
<dd>Format: string (valid ISO timestamp format). Specifies how time values should be displayed.</dd>

<dt>timestampTimeNoSeconds</dt>
<dd>Format: string (valid ISO timestamp format). Specifies how time values without seconds should be displayed.</dd>

<dt>timestampTimezoneOffsetFormat</dt>
<dd>Format: string (valid ISO timestamp format). Specifies how timezone should be displayed.</dd>

<dt>use24hTime</dt>
<dd>Format: boolean. Set to <code>true</code> if you want time values to be displayed in 24-hours-day format.</dd>
</dl>


### Using Docker Compose

Expand Down
32 changes: 26 additions & 6 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This image installs all dependencies and "pre-builds" an Argus frontend
# site, intended as a stepstone builder image for a full production site.
# This image installs all dependencies and compiles the Argus frontend into a
# set of static files that can be served by any webserver.
# It should run with the git repo root as the build context
FROM node:16-bullseye
FROM node:16-bullseye AS build
WORKDIR /app

COPY . /app
Expand All @@ -10,7 +10,27 @@ RUN npm ci
RUN npx browserslist@latest --update-db
RUN npm run build

ONBUILD RUN npm run build
##########################################################
# production environment consisting only of nginx and the statically compiled
# Argus Frontend application files produced by the build stage
# FROM: https://mherman.org/blog/dockerizing-a-react-app/
FROM nginx:stable-alpine

# When used as an intermediate builder image, the complete set of statically
# built files to serve from the web server root can be copied from /app/build
COPY --from=build /app/build /usr/share/nginx/html

RUN apk add --update tini tree
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/docker-entrypoint.sh /
COPY docker/runtime-config-template.json /

ENV REACT_APP_BACKEND_URL=http://fake
ENV REACT_APP_ENABLE_WEBSOCKETS_SUPPORT=true
ENV REACT_APP_BACKEND_WS_URL=ws://fake
ENV REACT_APP_COOKIE_DOMAIN=fake
ENV REACT_APP_USE_SECURE_COOKIE=false
ENV REACT_APP_DEBUG=false
ENV REACT_APP_DEFAULT_AUTO_REFRESH_INTERVAL=30
ENV REACT_APP_REALTIME_SERVICE_MAX_RETRIES=5

ENTRYPOINT ["/sbin/tini", "-v", "--"]
CMD ["/docker-entrypoint.sh"]
82 changes: 26 additions & 56 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,6 @@ Whereas the top-level Dockerfile is development-oriented, this directory
contains definitions to build a production-oriented Docker image of the Argus
front-end / user interface application.

However, as the front-end is a React-based application built from TypeScript
sources, the necessary configuration to talk to an Argus API back-end server is
staticailly compiled into the resulting web site.

This image can therefore only be used to serve as a intermediate build image,
to produce the actual statically compiled application with your site's settings
embedded.

To build this image, the build context needs to be that of the git repository
root. Something like this would work (when the current working directory is
here):
Expand All @@ -26,56 +18,34 @@ Or, from the top level directory:
docker build -t argus-frontend -f docker/Dockerfile .
```

## Using this intermediate image for building a production site

To build a complete Argus front-end application for your site, you need to
write a two-stage `Dockerfile`.

The first stage will be based on this image, while taking the necessary build
configuration as Docker build arguments, then building the React application
using `npm`. The resulting files should be served in the document root of any
web server. The can be copied from the path `/app/build` in the first stage
build container.

The image defined here will normally be published as
`ghcr.io/uninett/argus-frontend:<version>`. If you want to build a production
site from Argus frontend version *1.6.1*, you would write something like this:

```Dockerfile
FROM ghcr.io/uninett/argus-frontend:1.6.1 AS build

# These arguments are needed in the environment to properly configure and build
# Argus-frontend for this site:
ARG REACT_APP_BACKEND_URL=http://argus.example.org
ARG REACT_APP_ENABLE_WEBSOCKETS_SUPPORT=true
ARG REACT_APP_BACKEND_WS_URL=wss://argus.example.org/ws
ARG REACT_APP_USE_SECURE_COOKIE=true
ARG REACT_APP_DEBUG=true
ARG REACT_APP_DEFAULT_AUTO_REFRESH_INTERVAL=30
ARG REACT_APP_REALTIME_SERVICE_MAX_RETRIES=5
ARG REACT_APP_COOKIE_DOMAIN=argus.example.org

RUN npm run build

##########################################################
# Stage 2:
# production environment consisting only of nginx and the statically compiled
# Argus Frontend application files
# FROM: https://mherman.org/blog/dockerizing-a-react-app/
FROM nginx:stable-alpine

COPY --from=build /app/build /usr/share/nginx/html

RUN apk add --update tini tree
COPY nginx.conf /etc/nginx/conf.d/default.conf

ENTRYPOINT ["/sbin/tini", "-v", "--"]
CMD ["nginx", "-g", "daemon off;"]
The image defined here will normally be built and published automatically as
`ghcr.io/uninett/argus-frontend:<version>` every time a new Argus-frontend
version is release, meaning you don't necessarily have to build it from source
unless you want to make changes to how the image works.

## Running the Docker image in a container on a production site

The image will serve the Argus front-end application using a simple NGINX
server, listening internall on port *8080*. A `runtime-config.json`
configuration file will be created each time the container starts, constructed
from the same *environment* variables as described in
[../README.md](../README.md).

To serve a frontend that connects to an Argus API server at
https://argus.example.org you can run something like:

```sh
docker run \
-p 80:8080 \
-e REACT_APP_BACKEND_URL='https://argus.example.org' \
-e REACT_APP_BACKEND_WS_URL='wss://argus.example.org/ws' \
--name argus-web \
ghcr.io/uninett/argus-frontend:1.13.0
```

The first stage builds a set of static files based on your build arguments. The
second stage copies the produced file tree and serves it using an nginx web
server.
The argus front-end application should now be available to you at
http://localhost/


## Limitations

Expand Down
5 changes: 5 additions & 0 deletions docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
# Build the runtime config from environment:
envsubst < /runtime-config-template.json > /usr/share/nginx/html/runtime-config.json
# Now serve the static files forever:
exec nginx -g "daemon off;"
9 changes: 9 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
server {
listen 8080;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
17 changes: 17 additions & 0 deletions docker/runtime-config-template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"backendUrl": "${REACT_APP_BACKEND_URL}",
"backendWSUrl": "${REACT_APP_BACKEND_WS_URL}",
"enableWebsocketSupport": ${REACT_APP_ENABLE_WEBSOCKETS_SUPPORT},
"useSecureCookie": ${REACT_APP_USE_SECURE_COOKIE},
"debug": ${REACT_APP_DEBUG},
"cookieDomain": "${REACT_APP_COOKIE_DOMAIN}",
"defaultAutoRefreshInterval": ${REACT_APP_DEFAULT_AUTO_REFRESH_INTERVAL},
"realtimeServiceMaxRetries": ${REACT_APP_REALTIME_SERVICE_MAX_RETRIES},
"use24hTime": true,
"timestampDateFormat": "yyyy-MM-dd",
"timestampTimeFormat": "HH:mm:ss",
"timestampTimeNoSeconds": "HH:mm",
"timestampTimezoneOffsetFormat": "xxx",
"timestampFormat": "{date} {time}{timezone_offset}",
"showSeverityLevels": true
}
23 changes: 14 additions & 9 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ import {

import auth from "../auth";

import { ErrorType, debuglog, formatTimestamp } from "../utils";
import {ErrorType, debuglog, formatTimestamp} from "../utils";

import { BACKEND_URL, SHOW_SEVERITY_LEVELS } from "../config";
import { globalConfig } from "../config";
import { getErrorCause } from "./utils";

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -75,7 +75,6 @@ type CB = (response: AxiosResponse, error: ErrorType) => void;

const apiConfig = {
returnRejectedPromiseOnError: false,
baseURL: BACKEND_URL,
};

class ApiClient {
Expand All @@ -86,8 +85,8 @@ class ApiClient {
_listenersId: number;
_listeners: [number, ApiListener][];

public constructor(config?: AxiosRequestConfig) {
this.config = config || apiConfig;
public constructor(config: AxiosRequestConfig) {
this.config = config || {...apiConfig, baseURL: globalConfig.get().backendUrl};
this.api = axios.create(this.config);
this.token = auth.token();

Expand Down Expand Up @@ -119,13 +118,19 @@ class ApiClient {
});
}

public updateBaseUrl(newBaseUrl?: string, updatedConfig?: AxiosRequestConfig) {
this.config = updatedConfig ||
{...apiConfig, baseURL: newBaseUrl} ||
{...apiConfig, baseURL: globalConfig.get().backendUrl};
this.api = axios.create(this.config);
}

public registerInterceptors(unauthorizedCallback: CB, serverErrorCallback: CB, pluginErrorCallback: CB) {
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error && error.response) {
debuglog(error);

if (globalConfig.get().debug) debuglog(error);
const { status } = error.response;
const { url } = error.response.config; // endpoint relative url that was requested
const { data } = error.response; // error cause message
Expand Down Expand Up @@ -506,7 +511,7 @@ class ApiClient {
if (filter.filter.stateful !== undefined) {
params.push(`stateful=${filter.filter.stateful}`);
}
if (SHOW_SEVERITY_LEVELS && filter.filter.maxlevel !== undefined) {
if (globalConfig.get().showSeverityLevels && filter.filter.maxlevel !== undefined) {
params.push(`level__lte=${filter.filter.maxlevel}`);
}
if (filter.filter.sourceSystemIds !== undefined) {
Expand Down Expand Up @@ -767,4 +772,4 @@ class ApiClient {
}

// eslint-disable-next-line import/no-anonymous-default-export
export default new ApiClient(apiConfig);
export default new ApiClient({...apiConfig, baseURL: globalConfig.get().backendUrl});
6 changes: 3 additions & 3 deletions src/auth.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Cookies } from "react-cookie";
import {COOKIE_DOMAIN, USE_SECURE_COOKIE} from "./config";
import { globalConfig } from "./config";

const cookies = new Cookies();

Expand All @@ -15,14 +15,14 @@ class Auth {
this.authenticated = true;
this._token = token;

cookies.set("token", token, { path: "/", secure: USE_SECURE_COOKIE });
cookies.set("token", token, { path: "/", secure: globalConfig.get().useSecureCookie });

if (callback) callback();
}

logout(callback?: () => void) {
try {
cookies.remove("token", {path: "/", domain: `.${COOKIE_DOMAIN}`})
cookies.remove("token", {path: "/", domain: `.${globalConfig.get().cookieDomain}`})
cookies.remove("token", {path: "/"})
localStorage.removeItem("user");
} catch (e) { }
Expand Down
Loading

0 comments on commit 8570175

Please sign in to comment.