From mboxrd@z Thu Jan  1 00:00:00 1970
From: Michael Tremer <michael.tremer@ipfire.org>
To: development@lists.ipfire.org
Subject: Re: [PATCH 2/4] pakfire.cgi: Implement JavaScript log message display
Date: Thu, 02 Dec 2021 17:58:25 +0000
Message-ID: <18A11A6D-A0AC-4F7A-B1FC-5CE52C1C1F37@ipfire.org>
In-Reply-To: <7dac08bc-a83f-a400-bb29-6f950d2b3c30@leo-andres.de>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="===============3138576484898399943=="
List-Id: <development.lists.ipfire.org>

--===============3138576484898399943==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable

Hello,

> On 2 Dec 2021, at 16:30, Leo Hofmann <hofmann(a)leo-andres.de> wrote:
>=20
> Hi,
>=20
> Am 02.12.2021 um 16:59 schrieb Michael Tremer:
>> Hello,
>>=20
>>> On 2 Dec 2021, at 15:39, Leo-Andres Hofmann <hofmann(a)leo-andres.de> wro=
te:
>>>=20
>>> Currently the page becomes unresponsive while Pakfire is busy.
>>> This patch implements a AJAX/JSON driven log output, to provide
>>> continuous information to the user while Pakfire is running.
>>>=20
>>> The output is updated 1x per second, if the load should be too high,
>>> the interval can be change by writing to "pakfire.refreshInterval".
>>>=20
>>> Signed-off-by: Leo-Andres Hofmann <hofmann(a)leo-andres.de>
>>> ---
>>> html/cgi-bin/pakfire.cgi     | 153 ++++++++++++++++++----
>>> html/html/include/pakfire.js | 241 +++++++++++++++++++++++++++++++++++
>>> 2 files changed, 368 insertions(+), 26 deletions(-)
>>> create mode 100644 html/html/include/pakfire.js
>>>=20
>>> diff --git a/html/cgi-bin/pakfire.cgi b/html/cgi-bin/pakfire.cgi
>>> index 7957bc154..e5f5f7d6a 100644
>>> --- a/html/cgi-bin/pakfire.cgi
>>> +++ b/html/cgi-bin/pakfire.cgi
>>> @@ -36,8 +36,11 @@ my %color =3D ();
>>> my %pakfiresettings =3D ();
>>> my %mainsettings =3D ();
>>>=20
>>> -&Header::showhttpheaders();
>>> +# Load general settings
>>> +&General::readhash("${General::swroot}/main/settings", \%mainsettings);
>>> +&General::readhash("/srv/web/ipfire/html/themes/ipfire/include/colors.tx=
t", \%color);
>>>=20
>>> +# Get CGI request data
>>> $cgiparams{'ACTION'} =3D '';
>>> $cgiparams{'VALID'} =3D '';
>>>=20
>>> @@ -46,12 +49,102 @@ $cgiparams{'DELPAKS'} =3D '';
>>>=20
>>> &Header::getcgihash(\%cgiparams);
>>>=20
>>> -&General::readhash("${General::swroot}/main/settings", \%mainsettings);
>>> -&General::readhash("/srv/web/ipfire/html/themes/ipfire/include/colors.tx=
t", \%color);
>>> +### Process AJAX/JSON request ###
>>> +if($cgiparams{'ACTION'} eq 'json-getstatus') {
>>> +	# Send HTTP headers
>>> +	_start_json_output();
>>> +
>>> +	# Collect Pakfire status and log messages
>>> +	my %status =3D (
>>> +		'running' =3D> &_is_pakfire_busy() || "0",
>>> +		'running_since' =3D> &General::age("$Pakfire::lockfile") || "0s",
>>> +		'reboot' =3D> (-e "/var/run/need_reboot") || "0"
>>> +	);
>>> +	my @messages =3D `tac /var/log/messages | sed -n '/pakfire:/{p;/Pakfire=
.*started/q}'`;
>>> +
>>> +	# Start JSON file
>>> +	print "{\n";
>>> +
>>> +	foreach my $key (keys %status) {
>>> +		my $value =3D $status{$key};
>>> +		print qq{\t"$key": "$value",\n};
>>> +	}
>>> +
>>> +	# Print sanitized messages in reverse order to undo previous "tac"
>>> +	print qq{\t"messages": [\n};
>>> +	for my $index (reverse (0 .. $#messages)) {
>>> +		my $line =3D $messages[$index];
>>> +		$line =3D~ s/[[:cntrl:]<>&\\]+//g;
>>> +
>>> +		print qq{\t\t"$line"};
>>> +		print ",\n" unless $index < 1;
>>> +	}
>>> +	print "\n\t]\n";
>> What is the reason to =E2=80=9Ctac=E2=80=9D the log file first and then re=
verse the order again?
>>=20
>> Is it just to limit the length of the JSON array?
>>=20
>> It might be faster to read the entire file, grep out what we need and then=
 throw away most of the array. Or push a line to the end of the array and rem=
ove one from the beginning if it is longer than a certain threshold.
>=20
> I wanted to make sure that only the output of the current Pakfire run is sh=
own. Therefore, I use tac and sed to read the logfile backwards until the las=
t "Pakfire ... started!" header is reached.
> This works very well, but then of course the messages array is also in reve=
rse order.

Okay, that makes sense. Maybe we should start logging things into a separate =
file to make things easier?

/var/log/messages can become really large.

A C program is probably the fastest that we could ever have, so might be good=
 enough as a solution for me.

-Michael

>=20
> All the ideas I had required some form of "reverse", or I had to load the e=
ntire file in Perl and check every line. I assumed that tac & sed would be mo=
re efficient than any Perl solution I could come up with. I'll try to time th=
is and report back!
>=20
> Leo
>=20
>>=20
>>> +
>>> +	# Finalize JSON file & stop
>>> +	print "}";
>>> +	exit;
>>> +}
>>> +
>>> +### Start pakfire page ###
>>> +&Header::showhttpheaders();
>>> +
>>> +###--- HTML HEAD ---###
>>> +my $extraHead =3D <<END
>>> +<style>
>>> +	/* Pakfire log viewer */
>>> +	section#pflog-header {
>>> +		width: 100%;
>>> +		display: flex;
>>> +		text-align: left;
>>> +		align-items: center;
>>> +		column-gap: 20px;
>>> +	}
>>> +	#pflog-header > div:last-child {
>>> +		margin-left: auto;
>>> +		margin-right: 20px;
>>> +	}
>>> +	#pflog-header span {
>>> +		line-height: 1.3em;
>>> +	}
>>> +	#pflog-header span:empty::before {
>>> +		content: "\\200b"; /* zero width space */
>>> +	}
>>> +
>>> +	pre#pflog-messages {
>>> +		margin-top: 0.7em;
>>> +		padding-top: 0.7em;
>>> +		border-top: 0.5px solid $Header::bordercolour;
>>>=20
>>> -&Header::openpage($Lang::tr{'pakfire configuration'}, 1);
>>> +		text-align: left;
>>> +		min-height: 15em;
>>> +		overflow-x: auto;
>>> +	}
>>> +</style>
>>> +
>>> +<script src=3D"/include/pakfire.js"></script>
>>> +<script>
>>> +	// Translations
>>> +	pakfire.i18n.load({
>>> +		'working': '$Lang::tr{'pakfire working'}',
>>> +		'finished': 'Pakfire is finished! Please check the log output.',
>>> +		'since': '$Lang::tr{'since'} ', //(space is intentional)
>>> +
>>> +		'link_return': '<a href=3D"$ENV{'SCRIPT_NAME'}">Return to Pakfire</a>',
>>> +		'link_reboot': '<a href=3D"/cgi-bin/shutdown.cgi">$Lang::tr{'needreboo=
t'}</a>'
>>> +	});
>>> +=09
>>> +	// AJAX auto refresh interval
>>> +	pakfire.refreshInterval =3D 1000;
>>> +</script>
>>> +END
>>> +;
>>> +###--- END HTML HEAD ---###
>>> +
>>> +&Header::openpage($Lang::tr{'pakfire configuration'}, 1, $extraHead);
>>> &Header::openbigbox('100%', 'left', '', $errormessage);
>>>=20
>>> +# Process Pakfire commands
>>> if (($cgiparams{'ACTION'} eq 'install') && (! &_is_pakfire_busy())) {
>>> 	my @pkgs =3D split(/\|/, $cgiparams{'INSPAKS'});
>>> 	if ("$cgiparams{'FORCE'}" eq "on") {
>>> @@ -170,29 +263,30 @@ if ($errormessage) {
>>> 	&Header::closebox();
>>> }
>>>=20
>>> -# Check if pakfire is already running.
>>> -if (&_is_pakfire_busy()) {
>>> -	&Header::openbox( 'Waiting', 1, "<meta http-equiv=3D'refresh' content=
=3D'10;'>" );
>>> -	print <<END;
>>> -	<table>
>>> -		<tr><td>
>>> -				<img src=3D'/images/indicator.gif' alt=3D'$Lang::tr{'active'}' title=
=3D'$Lang::tr{'active'}' />&nbsp;
>>> -			<td>
>>> -				$Lang::tr{'pakfire working'}
>>> -		<tr><td colspan=3D'2' align=3D'center'>
>>> -			<form method=3D'post' action=3D'$ENV{'SCRIPT_NAME'}'>
>>> -				<input type=3D'image' alt=3D'$Lang::tr{'reload'}' title=3D'$Lang::tr=
{'reload'}' src=3D'/images/view-refresh.png' />
>>> -			</form>
>>> -		<tr><td colspan=3D'2' align=3D'left'><code>
>>> -END
>>> -	my @output =3D `grep pakfire /var/log/messages | tail -20`;
>>> -	foreach (@output) {
>>> -		print "$_<br>";
>>> -	}
>>> -	print <<END;
>>> -			</code>
>>> -		</table>
>>> +# Show log output while Pakfire is running
>>> +if(&_is_pakfire_busy()) {
>>> +	&Header::openbox("100%", "center", "Pakfire");
>>> +
>>> +	print <<END
>>> +<section id=3D"pflog-header">
>>> +	<div><img src=3D"/images/indicator.gif" alt=3D"$Lang::tr{'active'}" tit=
le=3D"$Lang::tr{'pagerefresh'}"></div>
>>> +	<div>
>>> +		<span id=3D"pflog-status">$Lang::tr{'pakfire working'}</span><br>
>>> +		<span id=3D"pflog-time"></span><br>
>>> +		<span id=3D"pflog-action"></span>
>>> +	</div>
>>> +	<div><a href=3D"$ENV{'SCRIPT_NAME'}"><img src=3D"/images/view-refresh.p=
ng" alt=3D"$Lang::tr{'refresh'}" title=3D"$Lang::tr{'refresh'}"></a></div>
>>> +</section>
>>> +
>>> +<!-- Pakfire log messages -->
>>> +<pre id=3D"pflog-messages"></pre>
>>> +<script>
>>> +	pakfire.running =3D true;
>>> +</script>
>>> +
>>> END
>>> +;
>>> +
>>> 	&Header::closebox();
>>> 	&Header::closebigbox();
>>> 	&Header::closepage();
>>> @@ -320,3 +414,10 @@ sub _is_pakfire_busy {
>>> 	# Test presence of PID or lockfile
>>> 	return (($pakfire_pid) || (-e "$Pakfire::lockfile"));
>>> }
>>> +
>>> +# Send HTTP headers
>>> +sub _start_json_output {
>>> +	print "Cache-Control: no-cache, no-store\n";
>>> +	print "Content-Type: application/json\n";
>>> +	print "\n"; # End of HTTP headers
>>> +}
>>> diff --git a/html/html/include/pakfire.js b/html/html/include/pakfire.js
>>> new file mode 100644
>>> index 000000000..0950870e0
>>> --- /dev/null
>>> +++ b/html/html/include/pakfire.js
>>> @@ -0,0 +1,241 @@
>>> +/*######################################################################=
#######
>>> +#                                                                       =
      #
>>> +# IPFire.org - A linux based firewall                                   =
      #
>>> +# Copyright (C) 2007-2021  IPFire Team  <info(a)ipfire.org>             =
        #
>>> +#                                                                       =
      #
>>> +# This program is free software: you can redistribute it and/or modify  =
      #
>>> +# it under the terms of the GNU General Public License as published by  =
      #
>>> +# the Free Software Foundation, either version 3 of the License, or     =
      #
>>> +# (at your option) any later version.                                   =
      #
>>> +#                                                                       =
      #
>>> +# This program is distributed in the hope that it will be useful,       =
      #
>>> +# but WITHOUT ANY WARRANTY; without even the implied warranty of        =
      #
>>> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         =
      #
>>> +# GNU General Public License for more details.                          =
      #
>>> +#                                                                       =
      #
>>> +# You should have received a copy of the GNU General Public License     =
      #
>>> +# along with this program.  If not, see <http://www.gnu.org/licenses/>. =
      #
>>> +#                                                                       =
      #
>>> +########################################################################=
#####*/
>>> +
>>> +"use strict";
>>> +
>>> +// Pakfire Javascript functions (requires jQuery)
>>> +class PakfireJS {
>>> +	constructor() {
>>> +		//--- Public properties ---
>>> +		// Translation strings
>>> +		this.i18n =3D new PakfireI18N();
>>> +
>>> +		//--- Private properties ---
>>> +		// Status flags (access outside constructor only with setter/getter)
>>> +		this._states =3D Object.create(null);
>>> +		this._states.running =3D false;
>>> +		this._states.reboot =3D false;
>>> +
>>> +		// Status refresh helper
>>> +		this._autoRefresh =3D {
>>> +			delay: 1000, //Delay between requests (default: 1s)
>>> +			jsonAction: 'getstatus', //CGI POST action parameter
>>> +			timeout: 5000, //XHR timeout (5s)
>>> +
>>> +			delayTimer: null, //setTimeout reference
>>> +			jqXHR: undefined, //jQuery.ajax promise reference
>>> +			get runningDelay() { //Waiting for end of delay
>>> +				return (this.delayTimer !=3D=3D null);
>>> +			},
>>> +			get runningXHR() { //Waiting for CGI response
>>> +				return (this.jqXHR && (this.jqXHR.state() =3D=3D=3D 'pending'));
>>> +			},
>>> +			get isRunning() {
>>> +				return (this.runningDelay || this.runningXHR);
>>> +			}
>>> +		};
>>> +	}
>>> +
>>> +	//### Public properties ###
>>> +
>>> +	// Pakfire is running (true/false)
>>> +	set running(state) {
>>> +		if(this._states.running !=3D=3D state) {
>>> +			this._states.running =3D state;
>>> +			this._states_onChange('running');
>>> +		}
>>> +	}
>>> +	get running() {
>>> +		return this._states.running;
>>> +	}
>>> +
>>> +	// Reboot needed (true/false)
>>> +	set reboot(state) {
>>> +		if(this._states.reboot !=3D=3D state) {
>>> +			this._states.reboot =3D state;
>>> +			this._states_onChange('reboot');
>>> +		}
>>> +	}
>>> +	get reboot() {
>>> +		return this._states.reboot;
>>> +	}
>>> +
>>> +	// Status refresh interval in ms
>>> +	set refreshInterval(delay) {
>>> +		if(delay < 500) {
>>> +			delay =3D 500; //enforce reasonable minimum
>>> +		}
>>> +		this._autoRefresh.delay =3D delay;
>>> +	}
>>> +	get refreshInterval() {
>>> +		return this._autoRefresh.delay;
>>> +	}
>>> +
>>> +	// Document loaded (call once from jQuery.ready)
>>> +	documentReady() {
>>> +		// Status refresh late start
>>> +		if(this.running && (! this._autoRefresh.isRunning)) {
>>> +			this._autoRefresh_runNow();
>>> +		}
>>> +	}
>>> +
>>> +	//### Private properties ###
>>> +
>>> +	// Pakfire status change handler
>>> +	// property: Affected status (running, reboot, ...)
>>> +	_states_onChange(property) {
>>> +		// Always update UI
>>> +		if(this.running) {
>>> +			$('#pflog-status').text(this.i18n.get('working'));
>>> +			$('#pflog-action').empty();
>>> +		} else {
>>> +			$('#pflog-status').text(this.i18n.get('finished'));
>>> +			if(this.reboot) { //Enable return or reboot links in UI
>>> +				$('#pflog-action').html(this.i18n.get('link_reboot'));
>>> +			} else {
>>> +				$('#pflog-action').html(this.i18n.get('link_return'));
>>> +			}
>>> +		}
>>> +
>>> +		// Start/stop status refresh if Pakfire started/stopped
>>> +		if(property =3D=3D=3D 'running') {
>>> +			if(this.running) {
>>> +				this._autoRefresh_runNow();
>>> +			} else {
>>> +				this._autoRefresh_clearSchedule();
>>> +			}
>>> +		}
>>> +	}
>>> +
>>> +	//--- Status refresh scheduling functions ---
>>> +
>>> +	// Immediately perform AJAX status refresh request
>>> +	_autoRefresh_runNow() {
>>> +		if(this._autoRefresh.runningXHR) {
>>> +			return; // Don't send multiple requests
>>> +		}
>>> +		this._autoRefresh_clearSchedule(); // Stop scheduled refresh, will sen=
d immediately
>>> +
>>> +		// Send AJAX request, attach listeners
>>> +		this._autoRefresh.jqXHR =3D this._JSON_get(this._autoRefresh.jsonActio=
n, this._autoRefresh.timeout);
>>> +		this._autoRefresh.jqXHR.done(function() { // Request succeeded
>>> +			if(this.running) { // Keep refreshing while Pakfire is running
>>> +				this._autoRefresh_scheduleRun();
>>> +			}
>>> +		});
>>> +		this._autoRefresh.jqXHR.fail(function() { // Request failed
>>> +			this._autoRefresh_scheduleRun(); // Try refreshing until valid status=
 is received
>>> +		});
>>> +	}
>>> +
>>> +	// Schedule next refresh
>>> +	_autoRefresh_scheduleRun() {
>>> +		if(this._autoRefresh.runningDelay || this._autoRefresh.runningXHR) {
>>> +			return; // Refresh already scheduled or in progress
>>> +		}
>>> +		this._autoRefresh.delayTimer =3D window.setTimeout(function() {
>>> +			this._autoRefresh.delayTimer =3D null;
>>> +			this._autoRefresh_runNow();
>>> +		}.bind(this), this._autoRefresh.delay);
>>> +	}
>>> +
>>> +	// Stop scheduled refresh (can still be refreshed up to 1x if XHR is al=
ready sent)
>>> +	_autoRefresh_clearSchedule() {
>>> +		if(this._autoRefresh.runningDelay) {
>>> +			window.clearTimeout(this._autoRefresh.delayTimer);
>>> +			this._autoRefresh.delayTimer =3D null;
>>> +		}
>>> +	}
>>> +
>>> +	//--- JSON request & data handling ---
>>> +
>>> +	// Load JSON data from Pakfire CGI, using a POST request
>>> +	// action: POST paramter "json-[action]"
>>> +	// maxTime: XHR timeout, 0 =3D no timeout
>>> +	_JSON_get(action, maxTime =3D 0) {
>>> +		return $.ajax({
>>> +			url: '/cgi-bin/pakfire.cgi',
>>> +			method: 'POST',
>>> +			timeout: maxTime,
>>> +			context: this,
>>> +			data: {'ACTION': `json-${action}`},
>>> +			dataType: 'json' //automatically check and convert result
>>> +		})
>>> +			.done(function(response) {
>>> +				this._JSON_process(action, response);
>>> +			});
>>> +	}
>>> +
>>> +	// Process successful response from Pakfire CGI
>>> +	// action: POST paramter "json-[action]" used to send request
>>> +	// data: JSON data object
>>> +	_JSON_process(action, data) {
>>> +		// Pakfire status refresh
>>> +		if(action =3D=3D=3D this._autoRefresh.jsonAction) {
>>> +			// Update status flags
>>> +			this.running =3D (data['running'] !=3D '0');
>>> +			this.reboot =3D (data['reboot'] !=3D '0');
>>> +
>>> +			// Update timer display
>>> +			if(this.running && data['running_since']) {
>>> +				$('#pflog-time').text(this.i18n.get('since') + data['running_since']=
);
>>> +			} else {
>>> +				$('#pflog-time').empty();
>>> +			}
>>> +
>>> +			// Print log messages
>>> +			let messages =3D "";
>>> +			data['messages'].forEach(function(line) {
>>> +				messages +=3D `${line}\n`;
>>> +			});
>>> +			$('#pflog-messages').text(messages);
>>> +		}
>>> +	}
>>> +}
>>> +
>>> +// Simple translation strings helper
>>> +// Format: {key: "translation"}
>>> +class PakfireI18N {
>>> +	constructor() {
>>> +		this._strings =3D Object.create(null); //Object without prototypes
>>> +	}
>>> +
>>> +	// Get translation
>>> +	get(key) {
>>> +		if(Object.prototype.hasOwnProperty.call(this._strings, key)) {
>>> +			return this._strings[key];
>>> +		}
>>> +		return `(undefined string '${key}')`;
>>> +	}
>>> +
>>> +	// Load key/translation object
>>> +	load(translations) {
>>> +		if(translations instanceof Object) {
>>> +			Object.assign(this._strings, translations);
>>> +		}
>>> +	}
>>> +}
>>> +
>>> +//### Initialize Pakfire ###
>>> +const pakfire =3D new PakfireJS();
>>> +
>>> +$(function() {
>>> +	pakfire.documentReady();
>>> +});
>>> --=20
>>> 2.27.0.windows.1


--===============3138576484898399943==--