diff --git a/.dockerignore b/.dockerignore
index 1e7c3d01..fc74711d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,3 +2,5 @@ node_modules
build
.dockerignore
Dockerfile
+docker/Dockerfile
+docker/README.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8103594..086c7070 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 4538b02f..7823676d 100644
--- a/README.md
+++ b/README.md
@@ -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:
- REACT_APP_BACKEND_URL
@@ -114,6 +146,55 @@ These environment variables are optional:
- Ignore it if Argus frontend and backend are deployed on the same domain. Otherwise, set it to the same value as
ARGUS_COOKIE_DOMAIN
variable on the backend.
+**Configuration** variables can be provided in `runtime-config.json` file and will take precedence over the **environment** variables. These **configuration** variables are available:
+
+
+ - backendUrl
+ - Format: string. The base URL to the Argus API server. MUST be provided in production environment, optional otherwise.
+
+ - cookieDomain
+ - Format: string. MUST be provided in production environment, optional otherwise.
+
+ - enableWebsocketSupport
+ - Format: boolean. Set to
true
to enable subscriptions to realtime incident updates.
+
+ - backendWSUrl
+ - 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
https://argus-api.example.org/
, this value could be wss://argus-api.example.org/ws
.
+
+ - realtimeServiceMaxRetries
+ - Format: integer. If you enable websocket support, and it fails, this specifies how many times the application will retry connection before closing the socket.
+
+ - defaultAutoRefreshInterval
+ - Format: integer. Set to the default number of seconds between each auto refresh.
+
+ - debug
+ - Format: boolean. Set to
true
if you want debug output from the application.
+
+ - showSeverityLevels/dt>
+
- Format: boolean. Set to
true
if you want to enable filtering of incidents by severity levels.
+
+ - useSecureCookie
+ - Format: boolean. Set explicitly to
false
to disable the use of secure cookies. Typically only useful when deploying the development environment using non-TLS servers/regular HTTP.
+
+ - timestampFormat
+ - Format: string (valid ISO timestamp format). Specifies how a complete timestamp should be displayed.
+
+ - timestampDateFormat
+ - Format: string (valid ISO timestamp format). Specifies how dates should be displayed.
+
+ - timestampTimeFormat
+ - Format: string (valid ISO timestamp format). Specifies how time values should be displayed.
+
+ - timestampTimeNoSeconds
+ - Format: string (valid ISO timestamp format). Specifies how time values without seconds should be displayed.
+
+ - timestampTimezoneOffsetFormat
+ - Format: string (valid ISO timestamp format). Specifies how timezone should be displayed.
+
+ - use24hTime
+ - Format: boolean. Set to
true
if you want time values to be displayed in 24-hours-day format.
+
+
### Using Docker Compose
diff --git a/docker/Dockerfile b/docker/Dockerfile
index a483ddf7..f0fff0fa 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -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
@@ -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"]
diff --git a/docker/README.md b/docker/README.md
index cf956a37..95ac5ba9 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -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):
@@ -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:`. 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:` 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
diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
new file mode 100755
index 00000000..646d78b0
--- /dev/null
+++ b/docker/docker-entrypoint.sh
@@ -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;"
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 00000000..24c08014
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,9 @@
+server {
+ listen 8080;
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/docker/runtime-config-template.json b/docker/runtime-config-template.json
new file mode 100644
index 00000000..d9f94a49
--- /dev/null
+++ b/docker/runtime-config-template.json
@@ -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
+}
diff --git a/src/api/client.ts b/src/api/client.ts
index 2ba7d397..e8d2e5ae 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -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
@@ -75,7 +75,6 @@ type CB = (response: AxiosResponse, error: ErrorType) => void;
const apiConfig = {
returnRejectedPromiseOnError: false,
- baseURL: BACKEND_URL,
};
class ApiClient {
@@ -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();
@@ -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
@@ -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) {
@@ -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});
diff --git a/src/auth.tsx b/src/auth.tsx
index 6f0dd91e..1d288f53 100644
--- a/src/auth.tsx
+++ b/src/auth.tsx
@@ -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();
@@ -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) { }
diff --git a/src/components/TimePicker.tsx b/src/components/TimePicker.tsx
index c2432c79..11d6612f 100644
--- a/src/components/TimePicker.tsx
+++ b/src/components/TimePicker.tsx
@@ -1,14 +1,14 @@
import React from "react";
import { KeyboardTimePicker, KeyboardTimePickerProps } from "@material-ui/pickers";
-import { USE_24H_TIME } from "../config";
+import { globalConfig } from "../config";
export type TimePickerPropsType = Exclude & {
use24hours?: boolean;
};
export const TimePicker: React.FC = ({
- use24hours = USE_24H_TIME,
+ use24hours = globalConfig.get().use24hTime,
...props
}: TimePickerPropsType) => {
return ;
diff --git a/src/components/alertsnackbar/index.tsx b/src/components/alertsnackbar/index.tsx
index e0dccc48..5614b78a 100644
--- a/src/components/alertsnackbar/index.tsx
+++ b/src/components/alertsnackbar/index.tsx
@@ -4,6 +4,7 @@ import Alert from "@material-ui/lab/Alert";
import MaterialUISnackbar from "@material-ui/core/Snackbar";
import { debuglog } from "../../utils";
+import {globalConfig} from "../../config";
export type AlertSnackbarSeverity = "error" | "warning" | "info" | "success";
@@ -89,7 +90,7 @@ export const useAlertSnackbar = (): UseAlertSnackbarResultType => {
const displayAlertSnackbar = (message: string, severity?: AlertSnackbarSeverity) => {
if (message === state.message && severity === state.severity && state.open) return;
- debuglog(`Displaying message with severity ${severity}: ${message}`);
+ if (globalConfig.get().debug) debuglog(`Displaying message with severity ${severity}: ${message}`);
setState((state: AlertSnackbarState) => {
return { ...state, open: true, message, severity: severity || "success", keepOpen: severity === "error" };
});
@@ -118,7 +119,7 @@ export const AlertSnackbarProvider = ({ children }: { children?: React.ReactNode
const component = ;
const displayAlertSnackbar = useCallback((message: string, severity?: AlertSnackbarSeverity) => {
- debuglog(`Displaying message with severity ${severity}: ${message}`);
+ if (globalConfig.get().debug) debuglog(`Displaying message with severity ${severity}: ${message}`);
setState((state: AlertSnackbarState) => {
return { ...state, open: true, message, severity: severity || "success", keepOpen: severity === "error" };
});
diff --git a/src/components/filteredincidentprovider.tsx b/src/components/filteredincidentprovider.tsx
index d582a917..00db3bf7 100644
--- a/src/components/filteredincidentprovider.tsx
+++ b/src/components/filteredincidentprovider.tsx
@@ -11,7 +11,7 @@ import { IncidentsStateType, IncidentsContext, createIncidentsIndex } from "../c
// Components
import { Tag, originalToTag } from "../components/tagselector";
-import { SHOW_SEVERITY_LEVELS } from "../config";
+import {globalConfig} from "../config";
// for all different tags "keys", THERE HAS TO BE ONE tag with
// matching value in incident.tags
@@ -49,7 +49,7 @@ export const matchesAcked = (incident: Incident, acked?: boolean): boolean => {
};
export const matchesMaxlevel = (incident: Incident, maxlevel?: SeverityLevelNumber): boolean => {
- if (!SHOW_SEVERITY_LEVELS || maxlevel === undefined) return true;
+ if (!globalConfig.get().showSeverityLevels || maxlevel === undefined) return true;
return incident.level <= maxlevel;
};
diff --git a/src/components/incident/IncidentDetails.tsx b/src/components/incident/IncidentDetails.tsx
index 0d2c6c72..bbaa096a 100644
--- a/src/components/incident/IncidentDetails.tsx
+++ b/src/components/incident/IncidentDetails.tsx
@@ -43,7 +43,7 @@ import { AckedItem, LevelItem, OpenItem, TicketItem } from "./Chips";
// Contexts/Hooks
import { useAlerts } from "../alertsnackbar";
import { useApiIncidentAcks, useApiIncidentEvents } from "../../api/hooks";
-import { SHOW_SEVERITY_LEVELS } from "../../config";
+import { globalConfig } from "../../config";
import "./IncidentDetails.css";
import {Hidden} from "@material-ui/core";
@@ -319,7 +319,7 @@ const IncidentDetails: React.FC = ({
Status
- {SHOW_SEVERITY_LEVELS && (
+ {globalConfig.get().showSeverityLevels && (
@@ -468,7 +468,7 @@ const IncidentDetails: React.FC = ({
Status
- {SHOW_SEVERITY_LEVELS && (
+ {globalConfig.get().showSeverityLevels && (
diff --git a/src/components/incident/IncidentFilterToolbar.tsx b/src/components/incident/IncidentFilterToolbar.tsx
index 64b7d3bb..ea6bc73d 100644
--- a/src/components/incident/IncidentFilterToolbar.tsx
+++ b/src/components/incident/IncidentFilterToolbar.tsx
@@ -44,7 +44,7 @@ import { SeverityLevelNumberNameMap } from "../../api/consts";
import api from "../../api";
// Config
-import { ENABLE_WEBSOCKETS_SUPPORT, SHOW_SEVERITY_LEVELS } from "../../config";
+import {globalConfig} from "../../config";
// Utils
import {
@@ -496,7 +496,7 @@ export const IncidentFilterToolbar: React.FC = (
}
}, [autoUpdateMethod, prevAutoUpdateMethod, displayAlert]);
- const autoUpdateOptions: AutoUpdateMethod[] = ENABLE_WEBSOCKETS_SUPPORT
+ const autoUpdateOptions: AutoUpdateMethod[] = globalConfig.get().enableWebsocketSupport
? ["never", "realtime", "interval"]
: ["never", "interval"];
@@ -518,7 +518,7 @@ export const IncidentFilterToolbar: React.FC = (
}
getColor={(selected: boolean) => (selected ? "primary" : "default")}
onSelect={(autoUpdate: AutoUpdateMethod) =>
- (autoUpdate === "realtime" ? ENABLE_WEBSOCKETS_SUPPORT : true) && setAutoUpdateMethod(autoUpdate)
+ (autoUpdate === "realtime" ? globalConfig.get().enableWebsocketSupport : true) && setAutoUpdateMethod(autoUpdate)
}
/>
@@ -601,7 +601,7 @@ export const IncidentFilterToolbar: React.FC = (
/>
- {SHOW_SEVERITY_LEVELS && (
+ {globalConfig.get().showSeverityLevels && (
= (
- {SHOW_SEVERITY_LEVELS && (
+ {globalConfig.get().showSeverityLevels && (
{
if (autoUpdateMethod === "interval") {
const interval = setInterval(() => {
refresh();
- }, 1000 * DEFAULT_AUTO_REFRESH_INTERVAL);
+ }, 1000 * globalConfig.get().defaultAutoRefreshInterval);
return () => clearInterval(interval);
}
}, [refresh, autoUpdateMethod]);
@@ -264,7 +264,7 @@ const FilteredIncidentTable = () => {
const autoUpdateTextOpts: Record = {
never: "not updating automatically",
realtime: "updating in real time",
- interval: `updating every ${DEFAULT_AUTO_REFRESH_INTERVAL}`,
+ interval: `updating every ${globalConfig.get().defaultAutoRefreshInterval}`,
};
const autoUpdateText = autoUpdateTextOpts[autoUpdateMethod as AutoUpdateMethod];
diff --git a/src/components/incidenttable/IncidentTable.tsx b/src/components/incidenttable/IncidentTable.tsx
index 0dfbb4da..46fba9ed 100644
--- a/src/components/incidenttable/IncidentTable.tsx
+++ b/src/components/incidenttable/IncidentTable.tsx
@@ -38,7 +38,7 @@ import IncidentTableToolbar from "../../components/incidenttable/IncidentTableTo
// Contexts/Hooks
import { useIncidentsContext } from "../incidentsprovider";
import { useAlerts } from "../alertsnackbar";
-import { SHOW_SEVERITY_LEVELS } from "../../config";
+import { globalConfig } from "../../config";
import {Collapse, Hidden, Box} from "@material-ui/core";
import {KeyboardArrowDown, KeyboardArrowUp} from "@material-ui/icons";
@@ -211,7 +211,7 @@ const MUIIncidentTable: React.FC = ({
Timestamp
Status
- {SHOW_SEVERITY_LEVELS && Severity level}
+ {globalConfig.get().showSeverityLevels && Severity level}
Source
Description
Actions
@@ -361,7 +361,7 @@ const MUIIncidentTable: React.FC = ({
{/* */}
- {SHOW_SEVERITY_LEVELS && (
+ {globalConfig.get().showSeverityLevels && (
@@ -405,7 +405,7 @@ const MUIIncidentTable: React.FC = ({
{formatTimestamp(incident.start_time)}
- {SHOW_SEVERITY_LEVELS && (
+ {globalConfig.get().showSeverityLevels && (
)}
@@ -453,7 +453,7 @@ const MUIIncidentTable: React.FC
= ({
{formatTimestamp(incident.start_time)}
- {SHOW_SEVERITY_LEVELS && (
+ {globalConfig.get().showSeverityLevels && (
)}
diff --git a/src/config.tsx b/src/config.tsx
index adf292e7..f01e38bb 100644
--- a/src/config.tsx
+++ b/src/config.tsx
@@ -1,13 +1,75 @@
-export const BACKEND_URL = process.env.REACT_APP_BACKEND_URL || "";
-export const ENABLE_WEBSOCKETS_SUPPORT = process.env.REACT_APP_ENABLE_WEBSOCKETS_SUPPORT === "true" || false;
-export const BACKEND_WS_URL = process.env.REACT_APP_BACKEND_WS_URL || "";
-export const USE_SECURE_COOKIE = process.env.REACT_APP_USE_SECURE_COOKIE !== "false";
-export const DEBUG = process.env.REACT_APP_DEBUG === "true" || false;
-export const FRONTEND_VERSION = require('../package.json').version;
-export const API_VERSION = require('../package.json').apiVersion;
+// Config values that must be explicitly provided in runtime-config.json
+export interface RequiredConfigValues {
+ backendUrl: string;
+ cookieDomain: string;
+}
+
+// Config values that could be omitted in runtime-config.json and will be set to reasonable default values
+export interface OptionalConfigValues {
+ useSecureCookie: boolean;
+ showSeverityLevels: boolean;
+ enableWebsocketSupport: boolean;
+ backendWSUrl: string;
+ debug: boolean;
+ defaultAutoRefreshInterval: number;
+ realtimeServiceMaxRetries: number;
+ use24hTime: boolean;
+ timestampDateFormat: string;
+ timestampTimeFormat: string;
+ timestampTimeNoSeconds: string;
+ timestampTimezoneOffsetFormat: string;
+ timestampFormat: string;
+}
+
+export type EditableConfig = RequiredConfigValues & Partial
+
+export class Config {
+ // Fixed values
+ readonly apiVersion: string = require('../package.json').version;
+ readonly frontendVersion: string = require('../package.json').version;
+
+ // Required values
+ backendUrl: string;
+ cookieDomain: string;
+
+ // Optional values
+ backendWSUrl: string;
+ debug: boolean;
+ defaultAutoRefreshInterval: number;
+ enableWebsocketSupport: boolean;
+ showSeverityLevels: boolean;
+ useSecureCookie: boolean;
+ realtimeServiceMaxRetries: number;
+ timestampDateFormat: string;
+ timestampFormat: string;
+ timestampTimeFormat: string;
+ timestampTimeNoSeconds: string;
+ timestampTimezoneOffsetFormat: string;
+ use24hTime: boolean;
+
+ constructor(config: EditableConfig) {
-// Inspired by https://stackoverflow.com/a/8498668
-export const COOKIE_DOMAIN = process.env.REACT_APP_COOKIE_DOMAIN || document.createElement('a').hostname;
+ if (config.backendUrl !== undefined &&
+ config.cookieDomain !== undefined) {
+ this.backendUrl = config.backendUrl;
+ this.cookieDomain = config.cookieDomain;
+ } else throw new Error("Missing one or more of the required configuration values.")
+
+ this.useSecureCookie = config.useSecureCookie || process.env.REACT_APP_USE_SECURE_COOKIE !== "false";
+ this.showSeverityLevels = config.showSeverityLevels !== undefined ? config.showSeverityLevels : true;
+ this.backendWSUrl = config.backendWSUrl || process.env.REACT_APP_BACKEND_WS_URL || "";
+ this.debug = config.debug || process.env.REACT_APP_DEBUG === "true" || false;
+ this.defaultAutoRefreshInterval = config.defaultAutoRefreshInterval || refreshInterval;
+ this.enableWebsocketSupport = config.enableWebsocketSupport || process.env.REACT_APP_ENABLE_WEBSOCKETS_SUPPORT === "true" || false;
+ this.realtimeServiceMaxRetries = config.realtimeServiceMaxRetries || rtsRetries;
+ this.timestampFormat = config.timestampFormat || "{date} {time}{timezone_offset}";
+ this.timestampTimeNoSeconds = config.timestampTimeNoSeconds || "HH:mm";
+ this.timestampDateFormat = config.timestampDateFormat || "yyyy-MM-dd";
+ this.timestampTimeFormat = config.timestampTimeFormat || "HH:mm:ss";
+ this.timestampTimezoneOffsetFormat = config.timestampTimezoneOffsetFormat || "xxx";
+ this.use24hTime = config.use24hTime !== undefined ? config.use24hTime : true;
+ }
+}
let refreshInterval = 30;
if (process.env.REACT_APP_DEFAULT_AUTO_REFRESH_INTERVAL) {
@@ -15,37 +77,28 @@ if (process.env.REACT_APP_DEFAULT_AUTO_REFRESH_INTERVAL) {
if (parsedInterval > 0) refreshInterval = parsedInterval;
}
-export const DEFAULT_AUTO_REFRESH_INTERVAL = refreshInterval; // seconds
-
let rtsRetries = 7;
if (process.env.REACT_APP_REALTIME_SERVICE_MAX_RETRIES) {
const parsedRetries = Number.parseInt(process.env.REACT_APP_REALTIME_SERVICE_MAX_RETRIES);
if (parsedRetries > 0) rtsRetries = parsedRetries;
}
-// The number of times the realtime service will try to establish
-// a websocket connection, with exponential backoff, before declaring
-// it a failure, and stops retrying.
-export const REALTIME_SERVICE_MAX_RETRIES = rtsRetries;
-
-// Will be replaced by user-settable config later
-// NOTE: You need to update the TIMESTAMP_TIME_FORMAT
-// if you want timestamps to be 24h/AM-PM
-export const USE_24H_TIME = true;
-
-// The full timestamp format should include seconds and timezone offset
-// See here for format syntax: https://date-fns.org/v2.17.0/docs/format
-export const TIMESTAMP_DATE_FORMAT = "yyyy-MM-dd";
-export const TIMESTAMP_TIME_FORMAT = "HH:mm:ss";
-export const TIMESTAMP_TIME_NO_SECONDS = "HH:mm";
-export const TIMESTAMP_TIMEZONE_OFFSET_FORMAT = "xxx";
+export const defaultRequiredConfigValues: RequiredConfigValues = {
+ backendUrl: process.env.REACT_APP_BACKEND_URL || "",
+ cookieDomain: process.env.REACT_APP_COOKIE_DOMAIN || document.createElement('a').hostname
+}
-// String replacements are used on this format string to build
-// the timestamp. This allows for switching of time, and disabling
-// of timezone offset, etc.
-export const TIMESTAMP_FORMAT = "{date} {time}{timezone_offset}";
+class GlobalConfig {
+ config: Config = new Config(defaultRequiredConfigValues);
-// Flag used to toggle whether severity levels will be shown in the frontend or not
-export const SHOW_SEVERITY_LEVELS = true;
+ public get(): Config {
+ return this.config;
+ }
+ public set(value: EditableConfig) {
+ this.config = new Config(value);
+ }
+}
+export let globalConfig = new GlobalConfig();
+export const globalConfigUrl = "runtime-config.json";
diff --git a/src/index.tsx b/src/index.tsx
index 7be65725..4ffbfcda 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,19 +1,58 @@
-import React from "react";
+import React, {ReactElement} from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
-import { AppProvider } from "./state/contexts";
-import { BrowserRouter } from "react-router-dom";
+import {AppProvider} from "./state/contexts";
+import {BrowserRouter} from "react-router-dom";
+import {defaultRequiredConfigValues, globalConfig, globalConfigUrl} from "./config";
+import api from "./api";
+
+// Before rendering, first fetch the global config:
+const app: ReactElement =
+
+
+
+
+
+
+console.log("index.tsx, fetching global config from", globalConfigUrl);
+
+fetch(
+ globalConfigUrl
+ , {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ }
+ }
+)
+ .then(response => response.json())
+ .then((response) => {
+ globalConfig.set({...response})
+ api.updateBaseUrl(response.backendUrl)
+ return app;
+ })
+ .then((reactElement: ReactElement) => {
+ ReactDOM.render(
+ reactElement,
+ document.getElementById("root"),
+ );
+ })
+ .catch(e => {
+ if (process.env.NODE_ENV === "development") {
+ globalConfig.set(defaultRequiredConfigValues)
+ api.updateBaseUrl(defaultRequiredConfigValues.backendUrl)
+ ReactDOM.render(
+ app,
+ document.getElementById("root"),
+ );
+ } else {
+ console.log(e);
+ }
+ })
+;
-ReactDOM.render(
-
-
-
-
- ,
- document.getElementById("root"),
-);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
diff --git a/src/services/RealtimeService.ts b/src/services/RealtimeService.ts
index 26f9a656..b82eea62 100644
--- a/src/services/RealtimeService.ts
+++ b/src/services/RealtimeService.ts
@@ -1,4 +1,4 @@
-import { BACKEND_WS_URL, REALTIME_SERVICE_MAX_RETRIES } from "../config";
+import { globalConfig } from "../config";
import type { Incident } from "../api/types.d";
@@ -123,9 +123,9 @@ export class RealtimeService {
return;
}
- if (this.retries > REALTIME_SERVICE_MAX_RETRIES) {
+ if (this.retries > globalConfig.get().realtimeServiceMaxRetries) {
console.error(
- `[RealtimeService ${this.id}] refusing to connected, exceeded ${REALTIME_SERVICE_MAX_RETRIES} retires`,
+ `[RealtimeService ${this.id}] refusing to connected, exceeded ${globalConfig.get().realtimeServiceMaxRetries} retires`,
);
this.setState("failed");
return;
@@ -136,7 +136,7 @@ export class RealtimeService {
this.retries++;
- this.ws = new WebSocket(`${BACKEND_WS_URL}/open/`);
+ this.ws = new WebSocket(`${globalConfig.get().backendWSUrl}/open/`);
this.ws.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
diff --git a/src/utils.tsx b/src/utils.tsx
index 2968e80c..9be4835f 100644
--- a/src/utils.tsx
+++ b/src/utils.tsx
@@ -5,20 +5,14 @@ import formatDistance from "date-fns/formatDistance";
// Api
import type {Incident, User, Token} from "./api/types.d";
-import api from "./api";
-import auth from "./auth";
// Config
-import {
- DEBUG,
- TIMESTAMP_FORMAT,
- TIMESTAMP_DATE_FORMAT,
- TIMESTAMP_TIME_FORMAT,
- TIMESTAMP_TIME_NO_SECONDS,
- TIMESTAMP_TIMEZONE_OFFSET_FORMAT,
-} from "./config";
+import { globalConfig } from "./config";
import {Destination, DestinationPK, KnownProperties, Media} from "./api/types.d";
+import api from "./api";
+import auth from "./auth";
+
export type ErrorType = string | Error;
export interface IncidentWithFormattedTimestamp extends Incident {
@@ -71,8 +65,7 @@ export function getPropertyByPath(obj: T, path: string): any {
return objectGetPropertyByPathArray(obj, path.split("."));
}
-// eslint-disable-next-line
-export const debuglog = DEBUG ? console.log.bind(null, "[DEBUG]") : () => {};
+export const debuglog = console.log.bind(null, "[DEBUG]");
export function identity(inp: T): T {
return inp;
@@ -180,17 +173,17 @@ export type FormatTimestampOptions = Partial<{
export function formatTimestamp(timestamp: Date | string, options?: FormatTimestampOptions): string {
const dateTimestamp = new Date(timestamp);
- let formatString = TIMESTAMP_FORMAT;
- formatString = formatString.replace("{date}", TIMESTAMP_DATE_FORMAT);
+ let formatString = globalConfig.get().timestampFormat;
+ formatString = formatString.replace("{date}", globalConfig.get().timestampDateFormat);
if (options?.withSeconds) {
- formatString = formatString.replace("{time}", TIMESTAMP_TIME_FORMAT);
+ formatString = formatString.replace("{time}", globalConfig.get().timestampTimeFormat);
} else {
- formatString = formatString.replace("{time}", TIMESTAMP_TIME_NO_SECONDS);
+ formatString = formatString.replace("{time}", globalConfig.get().timestampTimeNoSeconds);
}
if (options?.withTimezoneOffset) {
- formatString = formatString.replace("{timezone_offset}", TIMESTAMP_TIMEZONE_OFFSET_FORMAT);
+ formatString = formatString.replace("{timezone_offset}", globalConfig.get().timestampTimezoneOffsetFormat);
} else {
formatString = formatString.replace("{timezone_offset}", "");
}
diff --git a/src/views/incident/IncidentView.tsx b/src/views/incident/IncidentView.tsx
index 8522dfa0..5c57c6dd 100644
--- a/src/views/incident/IncidentView.tsx
+++ b/src/views/incident/IncidentView.tsx
@@ -17,7 +17,7 @@ import {useApiState} from "../../state/hooks";
import SelectedFilterProvider from "../../components/filterprovider";
import IncidentsProvider from "../../components/incidentsprovider";
import {Helmet} from "react-helmet";
-import {FRONTEND_VERSION, API_VERSION} from "../../config";
+import { globalConfig } from "../../config";
import {useBackground} from "../../hooks";
const IncidentComponent = ({ autoUpdateMethod }: { autoUpdateMethod: AutoUpdateMethod }) => {
@@ -77,14 +77,14 @@ const IncidentView: React.FC = () => {
{ isMetadataFetchError ?
- API {API_VERSION},
- frontend v.{FRONTEND_VERSION}
+ API {globalConfig.get().apiVersion},
+ frontend v.{globalConfig.get().frontendVersion}
:
Backend v.{backendVersion},
API {apiVersion},
- frontend v.{FRONTEND_VERSION}
+ frontend v.{globalConfig.get().frontendVersion}
}