diff --git a/README.md b/README.md
index 5a5d1a3..667ce32 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ Provides a control panel interface to conveniently manage clusters (groups of id
- Protection rules, choose bot protection mode "none" (whitelist), proof-of-work or proof-of-work+captcha. Can be domain-wide or a domain+path. Path overrides domain-wide.
- Global override for protection mode, to enable for all domains in a cluster.
- Maintenance mode, disables proxying for selected domains and serves an "under maintenance" page from haproxy.
+- Statistics page with server and backend-level breakdowns based on haproxy stats socket data.
##### Todo:
- Improved cert management
diff --git a/api.js b/api.js
index e0bc324..c616ea2 100644
--- a/api.js
+++ b/api.js
@@ -61,6 +61,11 @@ export async function deleteFromMap(mapName, body, dispatch, errorCallback, rout
return ApiCall(`/forms/map/${mapName}/delete`, 'POST', body, dispatch, errorCallback, router, 0.5);
}
+// Stats
+export async function getStats(dispatch, errorCallback, router) {
+ return ApiCall('/stats.json', 'GET', null, dispatch, errorCallback, router);
+}
+
// Global toggle
export async function globalToggle(body, dispatch, errorCallback, router) {
return ApiCall('/forms/global/toggle', 'POST', body, dispatch, errorCallback, router, 0.5);
diff --git a/components/MenuLinks.js b/components/MenuLinks.js
index 22cb1bb..dc4fef8 100644
--- a/components/MenuLinks.js
+++ b/components/MenuLinks.js
@@ -108,6 +108,14 @@ export default withRouter(function MenuLinks({ router }) {
+
+
+
+
+ Statistics
+
+
+
diff --git a/controllers/account.js b/controllers/account.js
index 54c3cee..59bcca7 100644
--- a/controllers/account.js
+++ b/controllers/account.js
@@ -33,7 +33,70 @@ exports.accountData = async (req, res, next) => {
globalAcl: globalAcl === '1',
aRecords,
aaaaRecords,
- }
+ };
+};
+
+/**
+ * stats data
+ */
+exports.statsData = async (req, res, next) => {
+ let serverStats = []
+ , frontendStats = [];
+ ([serverStats, frontendStats] = await Promise.all([
+ res.locals.dataPlaneAll('getStats', { type: 'server', parent: 'servers' }, null, null, true),
+ res.locals.dataPlaneAll('getStats', { type: 'frontend' }, null, null, true)
+ ]));
+ frontendStats.forEach(s => {
+ s[0].stats = s[0].stats
+ .filter(t => t.name === 'www-http-https')
+ .map(t => ({
+ 'name': t.name,
+ 'stats': {
+ 'Bytes in': t.stats.bin,
+ 'Bytes out': t.stats.bout,
+ 'Conn rate': t.stats.conn_rate,
+ 'Cr (max)': t.stats.conn_rate_max,
+ 'Request rate': t.stats.req_rate,
+ 'Rr (max)': t.stats.req_rate_max,
+ '1xx': t.stats.hrsp_1xx,
+ '2xx': t.stats.hrsp_2xx,
+ '3xx': t.stats.hrsp_3xx,
+ '4xx': t.stats.hrsp_4xx,
+ '5xx': t.stats.hrsp_5xx,
+ 'Total': t.stats.req_tot,
+ }
+ }));
+ });
+ serverStats.forEach(host => {
+ host.forEach(server => {
+ server.stats = server.stats
+ .filter(t => t.backend_name === 'servers')
+ .map(t => ({
+ 'name': t.name,
+ 'backend_name': t.backend_name,
+ 'stats': {
+ 'Address': t.stats.addr,
+ 'Bytes in': t.stats.bin,
+ 'Bytes out': t.stats.bout,
+ 'Sess rate': t.stats.rate,
+ 'Sr (max)': t.stats.rate_max,
+ 'Queue': t.stats.qcur,
+ 'Q (max)': t.stats.qmax,
+ 'Q (time)': t.stats.qtime,
+ '1xx': t.stats.hrsp_1xx,
+ '2xx': t.stats.hrsp_2xx,
+ '3xx': t.stats.hrsp_3xx,
+ '4xx': t.stats.hrsp_4xx,
+ '5xx': t.stats.hrsp_5xx,
+ 'Total': t.stats.req_tot,
+ }
+ }));
+ });
+ });
+ return {
+ serverStats,
+ frontendStats,
+ };
};
/**
@@ -63,6 +126,25 @@ exports.accountJson = async (req, res, next) => {
return res.json({ ...data, user: res.locals.user });
}
+
+/**
+ * GET /stats
+ * stats page html
+ */
+exports.statsPage = async (app, req, res, next) => {
+ const data = await exports.statsData(req, res, next);
+ return app.render(req, res, '/stats', { ...data, user: res.locals.user });
+}
+
+/**
+ * GET /stats.json
+ * stats json
+ */
+exports.statsJson = async (req, res, next) => {
+ const data = await exports.statsData(req, res, next);
+ return res.json({ ...data, user: res.locals.user });
+}
+
/**
* POST /forms/global/toggle
* toggle global ACL
diff --git a/pages/stats.js b/pages/stats.js
new file mode 100644
index 0000000..91bc03c
--- /dev/null
+++ b/pages/stats.js
@@ -0,0 +1,130 @@
+import React, { useState, useEffect } from 'react';
+import Head from 'next/head';
+import BackButton from '../components/BackButton.js';
+import ErrorAlert from '../components/ErrorAlert.js';
+import * as API from '../api.js'
+import { useRouter } from 'next/router';
+
+export default function Domains(props) {
+
+ const router = useRouter();
+ const [state, dispatch] = useState(props);
+ const [error, setError] = useState();
+
+ useEffect(() => {
+ if (!state.user) {
+ API.getStats(dispatch, setError, router);
+ }
+ }, [state.user, router]);
+
+ if (!state.user) {
+ return (
+
+ {error &&
}
+
+ Loading...
+
+
+ );
+ }
+
+ const { user, frontendStats, serverStats } = state;
+
+ //TODO: refresh function
+
+ const clusterServers = user.clusters[user.activeCluster]
+ .split(',')
+ .map(s => {
+ return new URL(s).hostname;
+ })
+
+ return (
+ <>
+
+
+ Statistics
+
+
+ {error && }
+
+
+ Frontend Stats:
+
+
+
+
+
+
+
+ Hostname |
+ {Object.keys(frontendStats[0][0].stats.find(s => s.name === 'www-http-https').stats)
+ .map(key => {key} | )}
+
+
+ {frontendStats
+ .map(s => {
+ return s[0].stats.find(k => k.name === 'www-http-https').stats;
+ })
+ .map((s, i) => {
+ return (
+ {clusterServers[i]} |
+ {Object.values(s)
+ .map((v, j) => {v} | )}
+
);
+ })}
+
+
+
+
+
+
+ Backend Stats:
+
+
+
+
+
+
+
+ Hostname |
+ Server |
+ {Object.keys(serverStats[0][0].stats.find(s => s.backend_name === 'servers').stats)
+ .map(key => {key} | )}
+
+
+ {serverStats
+ .map((host, hi) => {
+ return host.map(s => {
+ // return s[0].stats.find(k => k.backend_name === 'servers');
+ return s.stats.map((s, i) => {
+ return (
+ {clusterServers[hi]} |
+ {s.name} |
+ {Object.values(s.stats)
+ .map((v, j) => {v} | )}
+
);
+ })
+ })
+ .reduce((acc, arr) => {
+ return acc.concat(arr);
+ }, []);
+ })
+ .reduce((acc, arr) => {
+ return acc.concat(arr);
+ }, [])}
+
+
+
+
+
+ {/* back to account */}
+
+
+ >
+ );
+
+}
+
+export async function getServerSideProps({ req, res, query, resolvedUrl, locale, locales, defaultLocale}) {
+ return { props: { user: res.locals.user || null, ...query } }
+}
diff --git a/router.js b/router.js
index 63d45e3..64b2a6e 100644
--- a/router.js
+++ b/router.js
@@ -98,14 +98,14 @@ const testRouter = (server, app) => {
apiInstance.defaults.baseURL = `${firstClusterURL.origin}/v2`;
res.locals.dataPlane = apiInstance;
- res.locals.dataPlaneAll = async (operationId, parameters, data, config) => {
+ res.locals.dataPlaneAll = async (operationId, parameters, data, config, all=false) => {
const promiseResults = await Promise.all(clusterUrls.map(clusterUrl => {
const singleApi = new OpenAPIClientAxios({ definition, axiosConfigDefaults: { headers: { 'authorization': `Basic ${base64Auth}` } } });
const singleApiInstance = singleApi.initSync();
singleApiInstance.defaults.baseURL = `${clusterUrl.origin}/v2`;
return singleApiInstance[operationId](parameters, data, { ...config, baseUrl: `${clusterUrl.origin}/v2` });
}));
- return promiseResults[0]; //TODO: better desync handling
+ return all ? promiseResults.map(p => p.data) : promiseResults[0]; //TODO: better desync handling
}
res.locals.fetchAll = async (path, options) => {
//used for stuff that dataplaneapi with axios seems to struggle with e.g. multipart body
@@ -161,6 +161,8 @@ const testRouter = (server, app) => {
server.get('/domains.json', useSession, fetchSession, checkSession, csrfMiddleware, domainsController.domainsJson);
server.get('/certs', useSession, fetchSession, checkSession, useHaproxy, csrfMiddleware, certsController.certsPage.bind(null, app));
server.get('/certs.json', useSession, fetchSession, checkSession, useHaproxy, csrfMiddleware, certsController.certsJson);
+ server.get('/stats', useSession, fetchSession, checkSession, useHaproxy, csrfMiddleware, accountController.statsPage.bind(null, app));
+ server.get('/stats.json', useSession, fetchSession, checkSession, useHaproxy, csrfMiddleware, accountController.statsJson);
const clusterRouter = express.Router({ caseSensitive: true });
clusterRouter.post('/global/toggle', useSession, fetchSession, checkSession, useHaproxy, hasCluster, csrfMiddleware, accountController.globalToggle);
clusterRouter.post(`/map/:name(${mapNamesOrString})/add`, useSession, fetchSession, checkSession, useHaproxy, hasCluster, csrfMiddleware, mapsController.patchMapForm); //add to MAP