Make job page poll for updates

This is a first go at having the job page update without refreshing.

The approach I’ve taken is to do all the rendering of HTML on the server side,
rather than use a Javascipt templating engine like mustache. This ensures that
we don’t have to maintain two sets of templates.

So the approach is to split the job page into partials. These partials can then:
- be included in the job page to render the whole page
- be rendered indivudually and then returned as a blob of HTML inside a JSON
  response

Then I’ve added a Javascript module which looks for areas of the page that should
be reloaded. For each area of the page it will poll a URL and re-render that
section of the page when it gets new HTML. It implements some throttling so that
API calls will never happen more frequently than 0.67 times/second.
This commit is contained in:
Chris Hill-Scott
2016-03-02 17:36:20 +00:00
parent 0e663e044f
commit b31c9fbc0d
10 changed files with 259 additions and 115 deletions

View File

@@ -0,0 +1,44 @@
(function(GOVUK, Modules) {
"use strict";
const interval = 1500; // milliseconds
GOVUK.timeCache = {};
GOVUK.resultCache = {};
let getter = function(resource, render) {
if (
GOVUK.resultCache[resource] &&
(Date.now() < GOVUK.timeCache[resource])
) {
render(GOVUK.resultCache[resource]);
} else {
GOVUK.timeCache[resource] = Date.now() + interval;
$.get(
resource,
response => render(GOVUK.resultCache[resource] = response)
);
}
};
let poller = (resource, key, component) => () => getter(
resource, response => component.html(response[key])
);
Modules.UpdateContent = function() {
this.start = function(component) {
const $component = $(component);
setInterval(
poller($component.data('resource'), $component.data('key'), $component),
interval / 5
);
};
};
})(window.GOVUK, window.GOVUK.Modules);

View File

@@ -4,7 +4,8 @@ import time
from flask import ( from flask import (
render_template, render_template,
abort abort,
jsonify
) )
from flask_login import login_required from flask_login import login_required
from notifications_python_client.errors import HTTPError from notifications_python_client.errors import HTTPError
@@ -15,8 +16,6 @@ from app.main import main
from app.main.dao import templates_dao from app.main.dao import templates_dao
from app.main.dao import services_dao from app.main.dao import services_dao
now = time.strftime('%H:%M')
@main.route("/services/<service_id>/jobs") @main.route("/services/<service_id>/jobs")
@login_required @login_required
@@ -24,7 +23,7 @@ def view_jobs(service_id):
try: try:
jobs = job_api_client.get_job(service_id)['data'] jobs = job_api_client.get_job(service_id)['data']
return render_template( return render_template(
'views/jobs.html', 'views/jobs/jobs.html',
jobs=jobs, jobs=jobs,
service_id=service_id service_id=service_id
) )
@@ -44,16 +43,16 @@ def view_job(service_id, job_id):
notifications = notification_api_client.get_notifications_for_service(service_id, job_id) notifications = notification_api_client.get_notifications_for_service(service_id, job_id)
finished = job['status'] == 'finished' finished = job['status'] == 'finished'
return render_template( return render_template(
'views/job.html', 'views/jobs/job.html',
notifications=notifications['notifications'], notifications=notifications['notifications'],
counts={ counts={
'queued': 0 if finished else job['notification_count'], 'queued': 0 if finished else job['notification_count'],
'sent': job['notification_count'] if finished else 0, 'sent': job['notification_count'] if finished else 0,
'failed': 0 'failed': 0,
'cost': u'£0.00'
}, },
uploaded_at=job['created_at'], uploaded_at=job['created_at'],
finished_at=job['updated_at'] if finished else None, finished_at=job['updated_at'] if finished else None,
cost=u'£0.00',
uploaded_file_name=job['original_file_name'], uploaded_file_name=job['original_file_name'],
template=Template( template=Template(
templates_dao.get_service_template_or_404(service_id, job['template'])['data'], templates_dao.get_service_template_or_404(service_id, job['template'])['data'],
@@ -70,9 +69,47 @@ def view_job(service_id, job_id):
raise e raise e
@main.route("/services/<service_id>/jobs/<job_id>.json")
@login_required
def view_job_updates(service_id, job_id):
service = services_dao.get_service_by_id_or_404(service_id)
try:
job = job_api_client.get_job(service_id, job_id)['data']
notifications = notification_api_client.get_notifications_for_service(service_id, job_id)
finished = job['status'] == 'finished'
return jsonify(**{
'counts': render_template(
'partials/jobs/count.html',
counts={
'queued': 0 if finished else job['notification_count'],
'sent': job['notification_count'] if finished else 0,
'failed': 0,
'cost': u'£0.00'
}
),
'notifications': render_template(
'partials/jobs/notifications.html',
notifications=notifications['notifications']
),
'status': render_template(
'partials/jobs/status.html',
uploaded_at=job['created_at'],
finished_at=job['updated_at'] if finished else None
),
})
except HTTPError as e:
if e.status_code == 404:
abort(404)
else:
raise e
@main.route("/services/<service_id>/jobs/<job_id>/notification/<string:notification_id>") @main.route("/services/<service_id>/jobs/<job_id>/notification/<string:notification_id>")
@login_required @login_required
def view_notification(service_id, job_id, notification_id): def view_notification(service_id, job_id, notification_id):
now = time.strftime('%H:%M')
return render_template( return render_template(
'views/notification.html', 'views/notification.html',
message=[ message=[

View File

@@ -0,0 +1,24 @@
{% from "components/big-number.html" import big_number %}
<ul class="grid-row job-totals">
<li class="column-one-quarter">
{{ big_number(
counts.queued, 'queued'
)}}
</li>
<li class="column-one-quarter">
{{ big_number(
counts.sent, 'sent'
)}}
</li>
<li class="column-one-quarter">
{{ big_number(
counts.failed,
'failed'
)}}
</li>
<li class="column-one-quarter">
{{ big_number(
counts.cost, 'total cost'
)}}
</li>
</ul>

View File

@@ -0,0 +1,22 @@
{% from "components/table.html" import list_table, field, right_aligned_field_heading %}
{% call(item) list_table(
notifications,
caption=uploaded_file_name,
caption_visible=False,
empty_message="No messages to show yet",
field_headings=[
'Recipient',
right_aligned_field_heading('Status')
]
) %}
{% call field() %}
{{ item.to }}
{% endcall %}
{% call field(
align='right',
status='error' if item.status == 'Failed' else 'default'
) %}
{{ item.status|title }} at {{ item.sent_at|format_time }}
{% endcall %}
{% endcall %}

View File

@@ -0,0 +1,9 @@
{% if finished_at %}
<p class='heading-small'>
Finished {{ finished_at|format_datetime }}
</p>
{% else %}
<p class='heading-small'>
Started {{ uploaded_at|format_datetime }}
</p>
{% endif %}

View File

@@ -1,97 +0,0 @@
{% extends "withnav_template.html" %}
{% from "components/table.html" import list_table, field, right_aligned_field_heading %}
{% from "components/big-number.html" import big_number %}
{% from "components/banner.html" import banner %}
{% from "components/sms-message.html" import sms_message %}
{% from "components/email-message.html" import email_message %}
{% block page_title %}
{{ uploaded_file_name }} GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">
{{ uploaded_file_name }}
</h1>
{% if 'sms' == template.template_type %}
<div class="grid-row">
<div class="column-two-thirds">
{{ sms_message(
template.formatted_as_markup,
)}}
</div>
</div>
{% elif 'email' == template.template_type %}
{{ email_message(
template.subject,
template,
from_address='{}@notifications.service.gov.uk'.format(service.email_from),
from_name=from_name
)}}
{% endif %}
{% if finished_at %}
<p class='heading-small'>
Finished {{ finished_at|format_datetime }}
</p>
{% else %}
<p class='heading-small'>
Started {{ uploaded_at|format_datetime }}
</p>
{% endif %}
<ul class="grid-row job-totals">
<li class="column-one-quarter">
{{ big_number(
counts.queued, 'queued'
)}}
</li>
<li class="column-one-quarter">
{{ big_number(
counts.sent, 'sent'
)}}
</li>
<li class="column-one-quarter">
{{ big_number(
counts.failed,
'failed'
)}}
</li>
<li class="column-one-quarter">
{{ big_number(
cost, 'total cost'
)}}
</li>
</ul>
{% if notifications %}
{% call(item) list_table(
notifications,
caption=uploaded_file_name,
caption_visible=False,
empty_message="Messages go here",
field_headings=[
'Recipient',
right_aligned_field_heading('Status')
]
) %}
{% call field() %}
{{ item.to }}
{% endcall %}
{% call field(
align='right',
status='error' if item.status == 'Failed' else 'default'
) %}
{{ item.status|title }} at {{ item.sent_at|format_time }}
{% endcall %}
{% endcall %}
{% else %}
<p>
<a href="{{ url_for(".view_job", service_id=service_id, job_id=job_id) }}">Refresh</a>
</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "withnav_template.html" %}
{% from "components/banner.html" import banner %}
{% from "components/sms-message.html" import sms_message %}
{% from "components/email-message.html" import email_message %}
{% block page_title %}
{{ uploaded_file_name }} GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">
{{ uploaded_file_name }}
</h1>
{% if 'sms' == template.template_type %}
<div class="grid-row">
<div class="column-two-thirds">
{{ sms_message(
template.formatted_as_markup,
)}}
</div>
</div>
{% elif 'email' == template.template_type %}
{{ email_message(
template.subject,
template,
from_address='{}@notifications.service.gov.uk'.format(service.email_from),
from_name=from_name
)}}
{% endif %}
{% if not finished_at %}
<div
data-module="update-content"
data-resource="{{url_for(".view_job_updates", service_id=service_id, job_id=job_id)}}"
data-key="status"
>
{% endif %}
{% include 'partials/jobs/status.html' %}
{% if not finished_at %}
</div>
{% endif %}
{% if not finished_at %}
<div
data-module="update-content"
data-resource="{{url_for(".view_job_updates", service_id=service_id, job_id=job_id)}}"
data-key="counts"
>
{% endif %}
{% include 'partials/jobs/count.html' %}
{% if not finished_at %}
</div>
{% endif %}
{% if not finished_at %}
<div
data-module="update-content"
data-resource="{{url_for(".view_job_updates", service_id=service_id, job_id=job_id)}}"
data-key="notifications"
>
{% endif %}
{% include 'partials/jobs/notifications.html' %}
{% if not finished_at %}
</div>
{% endif %}
{% endblock %}

View File

@@ -54,6 +54,7 @@ gulp.task('javascripts', () => gulp
paths.src + 'javascripts/autofocus.js', paths.src + 'javascripts/autofocus.js',
paths.src + 'javascripts/highlightTags.js', paths.src + 'javascripts/highlightTags.js',
paths.src + 'javascripts/fileUpload.js', paths.src + 'javascripts/fileUpload.js',
paths.src + 'javascripts/updateContent.js',
paths.src + 'javascripts/main.js' paths.src + 'javascripts/main.js'
]) ])
.pipe(plugins.babel({ .pipe(plugins.babel({

View File

@@ -1,5 +1,6 @@
from flask import url_for from flask import url_for
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import json
def test_should_return_list_of_all_jobs(app_, def test_should_return_list_of_all_jobs(app_,
@@ -21,17 +22,19 @@ def test_should_return_list_of_all_jobs(app_,
assert len(jobs) == 5 assert len(jobs) == 5
def test_should_show_page_for_one_job(app_, def test_should_show_page_for_one_job(
service_one, app_,
api_user_active, service_one,
mock_login, api_user_active,
mock_get_user, mock_login,
mock_get_user_by_email, mock_get_user,
mock_get_service, mock_get_user_by_email,
mock_get_service_template, mock_get_service,
job_data, mock_get_service_template,
mock_get_job, job_data,
mock_get_notifications): mock_get_job,
mock_get_notifications
):
service_id = job_data['service'] service_id = job_data['service']
job_id = job_data['id'] job_id = job_data['id']
file_name = job_data['original_file_name'] file_name = job_data['original_file_name']
@@ -45,3 +48,35 @@ def test_should_show_page_for_one_job(app_,
content = response.get_data(as_text=True) content = response.get_data(as_text=True)
assert "Test Service: Your vehicle tax is about to expire" in content assert "Test Service: Your vehicle tax is about to expire" in content
assert file_name in content assert file_name in content
def test_should_show_updates_for_one_job_as_json(
app_,
service_one,
api_user_active,
mock_login,
mock_get_user,
mock_get_user_by_email,
mock_get_service,
mock_get_service_template,
job_data,
mock_get_job,
mock_get_notifications
):
service_id = job_data['service']
job_id = job_data['id']
file_name = job_data['original_file_name']
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
response = client.get(url_for('main.view_job_updates', service_id=service_id, job_id=job_id))
assert response.status_code == 200
content = json.loads(response.get_data(as_text=True))
assert 'sent' in content['counts']
assert 'queued' in content['counts']
assert 'failed' in content['counts']
assert 'Recipient' in content['notifications']
assert 'Status' in content['notifications']
assert 'Started' in content['status']