From cc4314633ea9dfa7ccc4daf7603a5cff160b244e Mon Sep 17 00:00:00 2001 From: Dillon Shook Date: Sun, 22 Dec 2024 00:53:30 -0500 Subject: [PATCH] Goaccess log reporting (#167) * First steps getting GoAccess working * End to end flow starting to work * Some refinements on logs tab * Better looking log report page * Goaccess settings * Localization for goaccess settings * Fetch report through API and show in modal * Updated to work with live report files and format the names --- src/api/ApiManager.ts | 35 +++ src/api/HttpClient.ts | 8 +- .../apps/appDetails/AccessLogReports.tsx | 189 ++++++++++++++ src/containers/apps/appDetails/AppDetails.tsx | 118 ++++----- .../monitoring/GoAccessDescription.tsx | 31 +++ src/containers/monitoring/GoAccessInfo.tsx | 190 ++++++++++++++ .../monitoring/GoAccessSettingsForm.tsx | 236 ++++++++++++++++++ src/containers/monitoring/Monitoring.tsx | 13 +- src/locales/en-US.json | 27 ++ src/models/IGoAccessInfo.ts | 7 + 10 files changed, 776 insertions(+), 78 deletions(-) create mode 100644 src/containers/apps/appDetails/AccessLogReports.tsx create mode 100644 src/containers/monitoring/GoAccessDescription.tsx create mode 100644 src/containers/monitoring/GoAccessInfo.tsx create mode 100644 src/containers/monitoring/GoAccessSettingsForm.tsx create mode 100644 src/models/IGoAccessInfo.ts diff --git a/src/api/ApiManager.ts b/src/api/ApiManager.ts index f88ca324..c20ab7c6 100644 --- a/src/api/ApiManager.ts +++ b/src/api/ApiManager.ts @@ -1,5 +1,6 @@ import { IAppDef } from '../containers/apps/AppDefinition' import { ICaptainDefinition } from '../models/ICaptainDefinition' +import IGoAccessInfo from '../models/IGoAccessInfo' import { IProConfig, IProFeatures, @@ -527,6 +528,40 @@ export default class ApiManager { ) } + getGoAccessInfo(): Promise { + const http = this.http + + return Promise.resolve() // + .then(http.fetch(http.GET, '/user/system/goaccess', {})) + } + + updateGoAccessInfo(goAccessInfo: any) { + const http = this.http + + return Promise.resolve() // + .then( + http.fetch(http.POST, '/user/system/goaccess', { goAccessInfo }) + ) + } + getGoAccessReports(appName: string) { + const http = this.http + + return Promise.resolve() // + .then( + http.fetch( + http.GET, + `/user/system/goaccess/${appName}/files`, + {} + ) + ) + } + getGoAccessReport(reportUrl: string) { + const http = this.http + + return Promise.resolve() // + .then(http.fetch(http.GET, reportUrl, {})) + } + changePass(oldPassword: string, newPassword: string) { const http = this.http diff --git a/src/api/HttpClient.ts b/src/api/HttpClient.ts index ad980e05..789452ee 100644 --- a/src/api/HttpClient.ts +++ b/src/api/HttpClient.ts @@ -71,6 +71,7 @@ export default class HttpClient { }) .then(function (data) { if ( + typeof data.status == 'number' && data.status !== ErrorFactory.OKAY && data.status !== ErrorFactory.OK_PARTIALLY && data.status !== ErrorFactory.OKAY_BUILD_STARTED @@ -89,7 +90,12 @@ export default class HttpClient { // network request returns back. return new Promise(function (resolve, reject) { // data.data here is the "data" field inside the API response! {status: 100, description: "Login succeeded", data: {…}} - if (!self.isDestroyed) return resolve(data.data) + if (!self.isDestroyed) + return resolve( + typeof data.data !== 'undefined' + ? data.data + : data + ) Logger.dev('Destroyed then not called') }) }) diff --git a/src/containers/apps/appDetails/AccessLogReports.tsx b/src/containers/apps/appDetails/AccessLogReports.tsx new file mode 100644 index 00000000..30aa2a41 --- /dev/null +++ b/src/containers/apps/appDetails/AccessLogReports.tsx @@ -0,0 +1,189 @@ +import { Button, Modal } from 'antd' +import { Component } from 'react' +import { localize } from '../../../utils/Language' +import { AppDetailsTabProps } from './AppDetails' + +interface GoAccessReport { + name: string + lastModifiedTime: string + domainName: string + url: string +} +interface LogReportState { + reportList: GoAccessReport[] + reportOpen: string | undefined + reportHtml: string | undefined +} +export default class AccessLogReports extends Component< + AppDetailsTabProps, + LogReportState +> { + constructor(props: any) { + super(props) + this.state = { + reportList: [], + reportOpen: undefined, + reportHtml: undefined, + } + } + render() { + const groupedReports = this.state.reportList.reduce( + (acc: { [key: string]: GoAccessReport[] }, report) => { + const date = new Date(report.lastModifiedTime) + const year = date.getFullYear() + const month = date.toLocaleDateString(undefined, { + month: 'long', + }) + const key = `${year} - ${month}` + + if (!acc[key]) { + acc[key] = [] + } + + acc[key].push(report) + + return acc + }, + {} + ) + const groupList = Object.entries(groupedReports) + .map(([key, reports]) => ({ + key, + reports, + time: new Date(reports[0].lastModifiedTime).getTime(), + })) + .sort((a, b) => b.time - a.time) + + return ( + <> +
+ {groupList.map(({ key, reports }) => ( +
+

{key}

+
+ {Object.entries( + reports.reduce( + ( + acc: { + [key: string]: GoAccessReport[] + }, + report + ) => { + if (!acc[report.domainName]) { + acc[report.domainName] = [] + } + acc[report.domainName].push(report) + return acc + }, + {} + ) + ).map(([domainName, reports]) => ( +
+

{domainName}

+ {reports.map((r) => ( +

+ +

+ ))} +
+ ))} +
+
+
+ ))} +
+ + } + title={this.state.reportOpen} + open={this.state.reportOpen !== undefined} + onCancel={() => this.onModalClose()} + loading={!this.state.reportHtml} + width="90vw" + style={{ top: 20 }} + > + {this.state.reportHtml && ( + + )} + + + ) + } + + componentDidMount() { + this.reFetchData() + } + + reFetchData() { + const self = this + this.props.setLoading(true) + + this.props.apiManager + .getGoAccessReports(this.props.apiData!.appDefinition.appName!) + .then((response) => { + self.setState({ reportList: response }) + this.props.setLoading(false) + }) + } + + reportName(report: GoAccessReport) { + if (report.name.includes('Live')) { + return localize('goaccess_settings.live', 'Live') + } + const date = new Date(report.lastModifiedTime) + + return date.toLocaleString(undefined, { + dateStyle: 'short', + timeStyle: 'medium', + }) + } + + onReportClick(reportName: string, reportUrl: string) { + this.setState({ reportOpen: reportName }) + + this.props.apiManager.getGoAccessReport(reportUrl).then((report) => { + this.setState({ reportHtml: report }) + }) + } + + onModalClose() { + this.setState({ reportOpen: undefined, reportHtml: undefined }) + } +} diff --git a/src/containers/apps/appDetails/AppDetails.tsx b/src/containers/apps/appDetails/AppDetails.tsx index d3af4d61..7bb21ea8 100644 --- a/src/containers/apps/appDetails/AppDetails.tsx +++ b/src/containers/apps/appDetails/AppDetails.tsx @@ -27,6 +27,7 @@ import { RouteComponentProps } from 'react-router' import ApiManager from '../../../api/ApiManager' import ProjectSelector from '../../../components/ProjectSelector' import { IMobileComponent } from '../../../models/ContainerProps' +import IGoAccessInfo from '../../../models/IGoAccessInfo' import { IHashMapGeneric } from '../../../models/IHashMapGeneric' import ProjectDefinition from '../../../models/ProjectDefinition' import { localize } from '../../../utils/Language' @@ -39,6 +40,7 @@ import ErrorRetry from '../../global/ErrorRetry' import { IAppDef } from '../AppDefinition' import onDeleteAppClicked from '../DeleteAppConfirm' import EditableSpan from '../EditableSpan' +import AccessLogReports from './AccessLogReports' import AppConfigs from './AppConfigs' import HttpSettings from './HttpSettings' import Deployment from './deploy/Deployment' @@ -46,6 +48,7 @@ import Deployment from './deploy/Deployment' const WEB_SETTINGS = 'WEB_SETTINGS' const APP_CONFIGS = 'APP_CONFIGS' const DEPLOYMENT = 'DEPLOYMENT' +const ACCESS_LOGS = 'LOGS' export interface SingleAppApiData { appDefinition: IAppDef @@ -53,6 +56,7 @@ export interface SingleAppApiData { captainSubDomain: string defaultNginxConfig: string projects: ProjectDefinition[] + goAccessInfo: IGoAccessInfo } export interface AppDetailsTabProps { @@ -328,6 +332,22 @@ class AppDetails extends ApiComponent< return } + const tabProps: AppDetailsTabProps = { + isMobile: this.props.isMobile, + setLoading: (value) => + this.setState({ + isLoading: value, + }), + reFetchData: () => this.reFetchData(), + apiData: Utils.copyObject(this.state.apiData!), + apiManager: this.apiManager, + updateApiData: (newData: any) => + this.setState({ + apiData: newData, + }), + onUpdateConfigAndSave: () => self.onUpdateConfigAndSave(), + } + return ( {self.createEditAppModal()} @@ -362,31 +382,7 @@ class AppDetails extends ApiComponent< )} ), - children: ( - - this.setState({ - isLoading: value, - }) - } - reFetchData={() => - this.reFetchData() - } - apiData={Utils.copyObject( - this.state.apiData! - )} - apiManager={this.apiManager} - updateApiData={(newData: any) => - this.setState({ - apiData: newData, - }) - } - onUpdateConfigAndSave={() => - self.onUpdateConfigAndSave() - } - /> - ), + children: , }, { key: APP_CONFIGS, @@ -398,31 +394,7 @@ class AppDetails extends ApiComponent< )} ), - children: ( - - this.setState({ - isLoading: value, - }) - } - reFetchData={() => - this.reFetchData() - } - apiData={Utils.copyObject( - this.state.apiData! - )} - apiManager={this.apiManager} - updateApiData={(newData: any) => - this.setState({ - apiData: newData, - }) - } - onUpdateConfigAndSave={() => { - self.onUpdateConfigAndSave() - }} - /> - ), + children: , }, { key: DEPLOYMENT, @@ -434,30 +406,23 @@ class AppDetails extends ApiComponent< )} ), - children: ( - - this.setState({ - isLoading: value, - }) - } - reFetchData={() => - this.reFetchData() - } - apiData={Utils.copyObject( - this.state.apiData! + children: , + }, + { + key: ACCESS_LOGS, + label: ( + + {localize( + 'apps.app_logs_tab', + 'Access Logs' )} - apiManager={this.apiManager} - onUpdateConfigAndSave={() => - self.onUpdateConfigAndSave() - } - updateApiData={(newData: any) => { - this.setState({ - apiData: newData, - }) - }} - /> + + ), + disabled: + !this.state.apiData?.goAccessInfo + .isEnabled, + children: ( + ), }, ]} @@ -476,7 +441,9 @@ class AppDetails extends ApiComponent<
+

+ {localize( + 'goaccess.description_details', + 'GoAccess is a server side Nginx log analyzer designed to generate self contained HTML reports in real-time with incremental log processing' + )} + . +

+

+ + {localize( + 'goaccess.more_details', + 'For more information, visit the' + )} + {' '} + + {localize('goaccess.link', 'GoAccess website')} + + . +

+
+ ) + } +} diff --git a/src/containers/monitoring/GoAccessInfo.tsx b/src/containers/monitoring/GoAccessInfo.tsx new file mode 100644 index 00000000..30fab33f --- /dev/null +++ b/src/containers/monitoring/GoAccessInfo.tsx @@ -0,0 +1,190 @@ +import { PoweroffOutlined } from '@ant-design/icons' +import { Button, Card, Col, Row, message } from 'antd' +import { connect } from 'react-redux' +import { IMobileComponent } from '../../models/ContainerProps' +import { localize } from '../../utils/Language' +import Toaster from '../../utils/Toaster' +import Utils from '../../utils/Utils' +import ApiComponent from '../global/ApiComponent' +import CenteredSpinner from '../global/CenteredSpinner' +import ErrorRetry from '../global/ErrorRetry' +import GoAccessDescription from './GoAccessDescription' +import GoAccessSettingsForm from './GoAccessSettingsForm' + +class GoAccessInfo extends ApiComponent< + { + isMobile: boolean + }, + { apiData: any; isLoading: boolean } +> { + constructor(props: any) { + super(props) + this.state = { + apiData: undefined, + isLoading: true, + } + } + + componentDidMount() { + this.refetchData() + } + + refetchData() { + const self = this + self.setState({ isLoading: true, apiData: undefined }) + return this.apiManager + .getGoAccessInfo() + .then(function (data) { + self.setState({ apiData: data }) + }) + .catch(Toaster.createCatcher()) + .then(function () { + self.setState({ isLoading: false }) + }) + } + + toggleClicked(isActivated: boolean) { + const goAccessInfo = Utils.copyObject(this.state.apiData) + goAccessInfo.isEnabled = !!isActivated + this.onUpdateGoAccessClicked(goAccessInfo) + } + + onUpdateGoAccessClicked(goAccessInfo: any) { + const self = this + self.setState({ isLoading: true }) + return this.apiManager + .updateGoAccessInfo(goAccessInfo) + .then(function () { + message.success( + goAccessInfo.isEnabled + ? localize('goaccess.started', 'GoAccess is started!') + : localize('goaccess.stopped', 'GoAccess has stopped') + ) + }) + .catch(Toaster.createCatcher()) + .then(function () { + self.refetchData() + }) + } + + render() { + const self = this + + if (this.state.isLoading) { + return + } + + if (!this.state.apiData) { + return + } + + const goAccessInfo = this.state.apiData + + return ( +
+ + + + +
+
+
+ + + +
+ +
+ +

+ + {localize( + 'goaccess.logs_location', + 'View access logs on the app details page' + )} + +

+ +
+
+
+
+ { + self.onUpdateGoAccessClicked( + goAccessInfo + ) + }} + /> +
+ + + +
+ ) + } +} + +function mapStateToProps(state: any) { + return { + isMobile: state.globalReducer.isMobile, + } +} + +export default connect( + mapStateToProps, + undefined +)(GoAccessInfo) diff --git a/src/containers/monitoring/GoAccessSettingsForm.tsx b/src/containers/monitoring/GoAccessSettingsForm.tsx new file mode 100644 index 00000000..e61412d9 --- /dev/null +++ b/src/containers/monitoring/GoAccessSettingsForm.tsx @@ -0,0 +1,236 @@ +import { Button, Form, Input, Row, Select } from 'antd' +import { Component } from 'react' +import IGoAccessInfo from '../../models/IGoAccessInfo' +import { localize } from '../../utils/Language' + +interface GoAccessSettingsProps { + goAccessInfo: IGoAccessInfo + saveSettings: (goAccessInfo: IGoAccessInfo) => void +} + +interface GoAccessSettingsState { + rotationFreqSelect: string | undefined + logRetentionSelect: string | undefined + + rotationFreqCustom: string | undefined + logRetentionCustom: number | undefined +} + +const CUSTOM = 'custom' + +export default class GoAccessSettingsForm extends Component< + GoAccessSettingsProps, + GoAccessSettingsState +> { + constructor(props: GoAccessSettingsProps) { + super(props) + this.state = { + rotationFreqSelect: this.valueOrCustom( + this.rotationFrequencyOptions, + props.goAccessInfo.data.rotationFrequencyCron + ), + logRetentionSelect: + props.goAccessInfo.data.logRetentionDays === undefined + ? '-1' + : this.valueOrCustom( + this.logRetentionOptions, + props.goAccessInfo.data.logRetentionDays.toString() + ), + rotationFreqCustom: props.goAccessInfo.data.rotationFrequencyCron, + logRetentionCustom: props.goAccessInfo.data.logRetentionDays, + } + } + + valueOrCustom( + options: { value: string | undefined }[], + value: string | undefined + ) { + return options.some((o) => o.value === value) ? value : CUSTOM + } + + rotationFrequencyOptions = [ + { + value: '0 0 * * 0', + label: localize('goaccess_settings.weekly', 'Weekly'), + }, + { + value: '0 0 1 * *', + label: localize('goaccess_settings.monthly', 'Monthly'), + }, + { + value: '0 0 1 */2 *', + label: localize( + 'goaccess_settings.every_2_months', + 'Every 2 Months' + ), + }, + { + value: '0 0 1 */4 *', + label: localize( + 'goaccess_settings.every_4_months', + 'Every 4 Months' + ), + }, + { + value: CUSTOM, + label: localize('goaccess_settings.custom', 'Custom'), + }, + ] + + logRetentionOptions = [ + { + value: '-1', + label: localize('goaccess_settings.indefinite', 'Indefinitely'), + }, + { value: '180', label: '180' }, + { value: '365', label: '365' }, + { + value: CUSTOM, + label: localize('goaccess_settings.custom', 'Custom'), + }, + ] + + updateRotationFrequency(selectValue?: string, customValue?: string) { + if (customValue !== undefined) { + this.setState({ rotationFreqCustom: customValue }) + } else { + this.setState({ rotationFreqSelect: selectValue }) + } + } + + updateLogRetention(selectValue?: string, customValue?: string) { + let updated: number | undefined = undefined + const input = selectValue ?? customValue + + if (input !== undefined) { + const parsed = parseInt(input) + if (!isNaN(parsed)) { + updated = parsed + } + } + + if (customValue !== undefined) { + this.setState({ logRetentionCustom: updated }) + } else { + this.setState({ logRetentionSelect: selectValue }) + } + } + + save() { + const updated = { ...this.props.goAccessInfo } + // Custom fields are required to submit so should always be filled out, but have defaults just in case and for type safety + + updated.data.rotationFrequencyCron = + (this.state.rotationFreqSelect === CUSTOM + ? this.state.rotationFreqCustom + : this.state.rotationFreqSelect) ?? '0 0 1 * *' + + updated.data.logRetentionDays = + this.state.logRetentionSelect === CUSTOM + ? this.state.logRetentionCustom + : Number(this.state.logRetentionSelect) + + // Handle indefinite log rotation specially + if (updated.data.logRetentionDays === -1) { + updated.data.logRetentionDays = undefined + } + + this.props.saveSettings(updated) + } + + render() { + return ( +
+
+

+ {localize( + 'goaccess_settings.goaccess_settings', + 'GoAccess Settings' + )} +

+
+
+ +
+ + + this.updateRotationFrequency( + undefined, + e.target.value + ) + } + /> + )} + + + + + this.updateLogRetention( + undefined, + e.target.value + ) + } + /> + )} + +
+
+
+ + + +
+ ) + } +} diff --git a/src/containers/monitoring/Monitoring.tsx b/src/containers/monitoring/Monitoring.tsx index 0770f611..32d7c2c0 100644 --- a/src/containers/monitoring/Monitoring.tsx +++ b/src/containers/monitoring/Monitoring.tsx @@ -1,14 +1,21 @@ -import React, { Component } from 'react' +import { Component } from 'react' +import GoAccessInfo from './GoAccessInfo' import LoadBalancerStats from './LoadBalancerStats' import NetDataInfo from './NetDataInfo' export default class Monitoring extends Component { render() { return ( -
+
-
+
) } diff --git a/src/locales/en-US.json b/src/locales/en-US.json index e8e210cc..c7c9aef0 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -63,6 +63,7 @@ "apps.app_config_vol_set_path": "Set specific host path", "apps.app_configs_tab": "App Configs", "apps.app_deployment_tab": "Deployment", + "apps.app_logs_tab": "Access Logs", "apps.app_edit_description": "Description", "apps.app_edit_name": "App name", "apps.app_edit_tags": "Tags", @@ -290,6 +291,32 @@ "docker_registry_table.password": "Password", "docker_registry_table.save_and_update": "Save and Update", "docker_registry_table.user": "User", + "goaccess.log_analyzer": "GoAccess Log Analyzer", + "goaccess.description_details": "GoAccess is a server side Nginx log analyzer designed to generate self contained HTML reports in real-time with incremental log processing", + "goaccess.more_details": "For more information, visit the", + "goaccess.link": "GoAccess website", + "goaccess.start_goaccess": "Start GoAccess", + "goaccess.stop_goaccess": "Stop GoAccess", + "goaccess.live": "Live", + "goaccess.logs_location": "View access logs on the app details page", + "goaccess.update": "Update GoAccess", + "goaccess.started": "GoAccess is started and updated!", + "goaccess.stopped": "GoAccess has stopped", + "goaccess_settings.custom": "Custom", + "goaccess_settings.every_minute": "Every Minute", + "goaccess_settings.every_10_minute": "Every 10 Minutes", + "goaccess_settings.every_hour": "Every Hour", + "goaccess_settings.every_day": "Every Day", + "goaccess_settings.weekly": "Weekly", + "goaccess_settings.monthly": "Monthly", + "goaccess_settings.every_2_months": "Every 2 Months", + "goaccess_settings.every_4_months": "Every 4 Months", + "goaccess_settings.indefinite": "Indefinitely", + "goaccess_settings.goaccess_settings": "GoAccess Settings", + "goaccess_settings.rotation_frequency": "Log Rotation and Report Generation Frequency", + "goaccess_settings.log_retention": "Log and Report Retention Days", + "goaccess_settings.crontab_placeholder": "Valid Crontab Expression", + "goaccess_settings.log_retention_placeholder": "Number of days", "load_balancer_stats.active_connections": "Active Connections", "load_balancer_stats.active_requests": "Active Requests", "load_balancer_stats.reading_requests": "reading", diff --git a/src/models/IGoAccessInfo.ts b/src/models/IGoAccessInfo.ts new file mode 100644 index 00000000..76ec3504 --- /dev/null +++ b/src/models/IGoAccessInfo.ts @@ -0,0 +1,7 @@ +export default interface IGoAccessInfo { + isEnabled: boolean + data: { + rotationFrequencyCron: string + logRetentionDays?: number + } +}