diff --git a/Makefile b/Makefile index f705231..23b817e 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ WAIT_GRAFANA = $(COMPOSE_RUN) dockerize -wait http://grafana:3000 -timeo # -- Targets sources := $(shell find src/ -type f -name '*.jsonnet') +libraries := $(shell find src/ -type f -name '*.libsonnet') targets := $(patsubst src/%.jsonnet,var/lib/grafana/%.json,$(sources)) # -- Plugins @@ -73,12 +74,12 @@ down: ## remove stack (warning: it removes the database container) @$(COMPOSE) down || echo WARNING: unable to remove the stack. Try to stop linked containers or networks first. .PHONY: down -format: ## format Jsonnet sources - bin/jsonnetfmt -i $(sources) +format: ## format Jsonnet sources and libraries + bin/jsonnetfmt -i $(sources) $(libraries) .PHONY: format -lint: ## lint Jsonnet sources - bin/jsonnet-lint $(sources) +lint: ## lint Jsonnet sources and libraries + bin/jsonnet-lint $(sources) $(libraries) .PHONY: lint logs: ## display grafana logs (follow mode) diff --git a/bin/jsonnet-lint b/bin/jsonnet-lint index 129b8e7..2fc3efb 100755 --- a/bin/jsonnet-lint +++ b/bin/jsonnet-lint @@ -7,4 +7,5 @@ DOCKER_USER="$(id -u):$(id -g)" # the dashboard directory is mounted in the container working directory, e.g. # src/dashboards is mounted in /app/dashboards (with /app as the working # directory). -DOCKER_USER=${DOCKER_USER} docker-compose run --rm app jsonnet-lint "${@//src\//}" +COMMAND="for file_name in ${@//src\//}; do jsonnet-lint \$file_name ; done" +DOCKER_USER=${DOCKER_USER} docker-compose run --rm app /bin/sh -c "$COMMAND" diff --git a/src/dashboards/videos/common.libsonnet b/src/dashboards/videos/common.libsonnet new file mode 100644 index 0000000..5791f9b --- /dev/null +++ b/src/dashboards/videos/common.libsonnet @@ -0,0 +1,90 @@ +local grafana = import 'grafonnet/grafana.libsonnet'; +local template = grafana.template; + +{ + field: { + actor_account_name: 'actor.account.name.keyword', + context_extensions_completion_threshold: 'context.extensions.https://w3id.org/xapi/video/extensions/completion-threshold', + course: 'object.definition.extensions.http://adlnet.gov/expapi/activities/course.keyword', + result_extensions_length: 'result.extensions.https://w3id.org/xapi/video/extensions/length', + result_extensions_time: 'result.extensions.https://w3id.org/xapi/video/extensions/time', + school: 'object.definition.extensions.https://w3id.org/xapi/acrossx/extensions/school.keyword', + session: 'object.definition.extensions.http://adlnet.gov/expapi/activities/module.keyword', + video_id: 'object.id.keyword', + }, + lrs: 'lrs', + method: { + double_escape_string(x):: std.strReplace(std.strReplace(x, ':', '\\\\:'), '/', '\\\\/'), + single_escape_string(x):: std.strReplace(std.strReplace(x, ':', '\\:'), '/', '\\/'), + }, + object: { + count_metric: { id: '1', type: 'count' }, + date_histogram(interval='auto', min_doc_count='1'):: { + id: 'date', + field: 'timestamp', + type: 'date_histogram', + settings: { + interval: interval, + min_doc_count: min_doc_count, + trimEdges: '0', + }, + }, + }, + query: { + school_course_session: '%(school)s:$SCHOOL AND %(course)s:$COURSE AND %(session)s:$SESSION' % { + school: $.method.single_escape_string($.field.school), + course: $.method.single_escape_string($.field.course), + session: $.method.single_escape_string($.field.session), + }, + }, + template: { + course: template.new( + name='COURSE', + current='all', + label='Course', + datasource=$.lrs, + query='{"find": "terms", "field": "%(course)s", "query": "%(school)s:$SCHOOL"}' % { + course: $.field.course, + school: $.method.double_escape_string($.field.school), + }, + refresh='time' + ), + school: template.new( + name='SCHOOL', + current='all', + label='School', + datasource=$.lrs, + query='{"find": "terms", "field": "%(school)s"}' % { school: $.field.school }, + refresh='time' + ), + session: template.new( + name='SESSION', + current='all', + label='Session', + datasource=$.lrs, + query='{"find": "terms", "field": "%(session)s", "query": "%(course)s:$COURSE"}' % { + session: $.field.session, + course: $.method.double_escape_string($.field.course), + }, + refresh='time' + ), + statements_interval: template.custom( + name='STATEMENTS_INTERVAL', + current='7d', + label='Statements interval', + query='1d,7d,14d,21d,28d', + refresh='time' + ), + view_count_threshold: template.custom( + name='VIEW_COUNT_THRESHOLD', + current='30', + label='View count threshold', + query='0,10,20,30,40,50,60', + refresh='time' + ), + }, + value: { + verb_id_completed: 'http://adlnet.gov/expapi/verbs/completed', + verb_id_played: 'https://w3id.org/xapi/video/verbs/played', + }, +} diff --git a/src/dashboards/videos/course.jsonnet b/src/dashboards/videos/course.jsonnet new file mode 100644 index 0000000..60250ea --- /dev/null +++ b/src/dashboards/videos/course.jsonnet @@ -0,0 +1,317 @@ +local grafana = import 'grafonnet/grafana.libsonnet'; +local dashboard = grafana.dashboard; +local elasticsearch = grafana.elasticsearch; +local barGaugePanel = grafana.barGaugePanel; +local graphPanel = grafana.graphPanel; +local statPanel = grafana.statPanel; + +local common = import 'common.libsonnet'; + + +dashboard.new( + 'Course', + tags=['xAPI', 'video', 'teacher'], + editable=false +) +.addTemplate(common.template.school) +.addTemplate(common.template.course) +.addTemplate(common.template.session) +.addTemplate(common.template.view_count_threshold) +.addTemplate(common.template.statements_interval) +.addPanel( + statPanel.new( + title='Views', + description=||| + A view is counted when the user has clicked the play button in the interface + in the first ${VIEW_COUNT_THRESHOLD} seconds of the video. + + Note that we count additional `views` each time the user plays or resumes + the video during the first seconds of the video. This time range is + controlled by the `View count threshold` variable. + |||, + datasource=common.lrs, + graphMode='none', + reducerFunction='sum' + ).addTarget( + elasticsearch.target( + datasource=common.lrs, + query='%(course_query)s AND verb.id:"%(verb_played)s" AND %(time)s:[0 TO $VIEW_COUNT_THRESHOLD]' % { + course_query: common.query.school_course_session, + verb_played: common.value.verb_id_played, + time: common.method.single_escape_string(common.field.result_extensions_time), + }, + metrics=[common.object.count_metric], + bucketAggs=[common.object.date_histogram('$STATEMENTS_INTERVAL')], + timeField='timestamp' + ) + ), + gridPos={ x: 0, y: 9, w: 6, h: 9 } +) +.addPanel( + statPanel.new( + title='Complete views', + description=||| + Total number of complete views of videos present in the selected course / session. + A view is considered as complete when the completion threshold of the video has been reached. + |||, + datasource=common.lrs, + graphMode='none', + reducerFunction='sum', + ).addTarget( + elasticsearch.target( + datasource=common.lrs, + query='%(course_query)s AND verb.id:"%(verb_completed)s"' % { + course_query: common.query.school_course_session, + verb_completed: common.value.verb_id_completed, + }, + metrics=[common.object.count_metric], + bucketAggs=[common.object.date_histogram()], + timeField='timestamp' + ) + ), + gridPos={ x: 6, y: 9, w: 6, h: 9 } +) +.addPanel( + graphPanel.new( + title='Views by ${STATEMENTS_INTERVAL}', + description=||| + A view is counted when the user has clicked the play button in the interface + in the first ${VIEW_COUNT_THRESHOLD} seconds of the video. + |||, + datasource=common.lrs, + ).addTarget( + elasticsearch.target( + datasource=common.lrs, + query='%(course_query)s AND verb.id:"%(verb_played)s" AND %(time)s:[0 TO $VIEW_COUNT_THRESHOLD]' % { + course_query: common.query.school_course_session, + verb_played: common.value.verb_id_played, + time: common.method.single_escape_string(common.field.result_extensions_time), + }, + metrics=[common.object.count_metric], + bucketAggs=[common.object.date_histogram('$STATEMENTS_INTERVAL')], + timeField='timestamp' + ) + ), + gridPos={ x: 12, y: 9, w: 12, h: 9 } +) +.addPanel( + { + title: 'Statements by user', + description: ||| + The count of statements by user. + On the X-axis is the number of statements. + On the Y-axis is the number of users. + |||, + datasource: common.lrs, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + displayName: 'User', + mappings: [], + }, + overrides: [], + }, + options: { + bucketOffset: 0, + legend: { + calcs: ['count', 'max', 'mean'], + displayMode: 'list', + placement: 'bottom', + }, + }, + pluginVersion: '8.0.3', + targets: [ + { + alias: '', + bucketAggs: [ + { + field: 'timestamp', + id: '2', + settings: { + interval: '3600000', + min_doc_count: '1', + }, + type: 'histogram', + }, + { + field: common.field.actor_account_name, + id: '3', + settings: { + min_doc_count: '1', + order: 'desc', + orderBy: '_term', + size: '0', + }, + type: 'terms', + }, + ], + metrics: [common.object.count_metric], + query: common.query.school_course_session, + refId: 'A', + timeField: 'timestamp', + }, + ], + transformations: [ + { + id: 'groupBy', + options: { + fields: { + Count: { + aggregations: [ + 'sum', + ], + operation: 'aggregate', + }, + [common.field.actor_account_name]: { + aggregations: [], + operation: 'groupby', + }, + }, + }, + }, + ], + type: 'histogram', + }, + gridPos={ x: 12, y: 18, w: 12, h: 9 } +) +.addPanel( + { + title: 'Completed videos by user', + description: ||| + The distribution of the number of times users completed videos. + On the X-axis is the number of completed videos. + On the Y-axis is the number of users. + |||, + datasource: common.lrs, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + displayName: 'User', + mappings: [], + }, + overrides: [], + }, + options: { + bucketOffset: 0, + legend: { + calcs: ['count', 'max', 'mean'], + displayMode: 'list', + placement: 'bottom', + }, + }, + pluginVersion: '8.0.3', + targets: [ + { + alias: '', + bucketAggs: [ + { + field: 'timestamp', + id: '2', + settings: { + interval: '3600000', + min_doc_count: '1', + }, + type: 'histogram', + }, + { + field: common.field.actor_account_name, + id: '3', + settings: { + min_doc_count: '1', + order: 'desc', + orderBy: '_term', + size: '0', + }, + type: 'terms', + }, + ], + metrics: [common.object.count_metric], + query: '%(course_query)s AND verb.id:"%(completed)s"' % { + course_query: common.query.school_course_session, + completed: common.value.verb_id_completed, + }, + refId: 'A', + timeField: 'timestamp', + }, + ], + transformations: [ + { + id: 'groupBy', + options: { + fields: { + Count: { + aggregations: [ + 'sum', + ], + operation: 'aggregate', + }, + [common.field.actor_account_name]: { + aggregations: [], + operation: 'groupby', + }, + }, + }, + }, + ], + type: 'histogram', + }, + gridPos={ x: 0, y: 27, w: 12, h: 9 } +) +.addPanel( + barGaugePanel.new( + title='Views by video', + description=||| + The total count of views by video. + |||, + datasource=common.lrs, + ).addTarget( + elasticsearch.target( + datasource=common.lrs, + query='%(course_query)s AND verb.id:"%(verb_played)s" AND %(time)s:[0 TO $VIEW_COUNT_THRESHOLD]' % { + course_query: common.query.school_course_session, + verb_played: common.value.verb_id_played, + time: common.method.single_escape_string(common.field.result_extensions_time), + }, + metrics=[common.object.count_metric], + bucketAggs=[ + { + field: common.field.video_id, + id: '2', + settings: { + min_doc_count: '1', + order: 'desc', + orderBy: '_count', + size: '0', + }, + type: 'terms', + }, + ], + timeField='timestamp' + ) + ) + { + options: { + displayMode: 'gradient', + orientation: 'horizontal', + reduceOptions: { + calcs: [ + 'lastNotNull', + ], + fields: '/^Count$/', + limit: 500, + values: true, + }, + showUnfilled: true, + text: {}, + }, + fieldConfig: { + defaults: { + color: { mode: 'thresholds' }, + }, + }, + }, + gridPos={ x: 0, y: 36, w: 12, h: 9 } +) diff --git a/src/dashboards/videos/statements.jsonnet b/src/dashboards/videos/statements.jsonnet new file mode 100644 index 0000000..422bd65 --- /dev/null +++ b/src/dashboards/videos/statements.jsonnet @@ -0,0 +1,217 @@ +local grafana = import 'grafonnet/grafana.libsonnet'; +local dashboard = grafana.dashboard; +local elasticsearch = grafana.elasticsearch; +local graphPanel = grafana.graphPanel; +local statPanel = grafana.statPanel; + +local common = import 'common.libsonnet'; + +dashboard.new( + 'Statements', + tags=['xAPI', 'video', 'staff'], + editable=false +) +.addTemplate(common.template.school) +.addTemplate(common.template.course) +.addTemplate(common.template.session) +.addTemplate(common.template.statements_interval) +.addPanel( + statPanel.new( + title='Statements', + description=||| + Total count of statements in the selected time range. + |||, + datasource=common.lrs, + reducerFunction='sum', + ).addTarget( + elasticsearch.target( + datasource=common.lrs, + query=common.query.school_course_session, + metrics=[common.object.count_metric], + bucketAggs=[common.object.date_histogram('$STATEMENTS_INTERVAL')], + timeField='timestamp' + ) + ), + gridPos={ x: 0, y: 0, w: 6, h: 6 } +) +.addPanel( + statPanel.new( + title='Videos', + description=||| + Total number of videos which had at least one interaction in the selected time range. + |||, + datasource=common.lrs, + reducerFunction='count', + ).addTarget( + elasticsearch.target( + datasource=common.lrs, + query=common.query.school_course_session, + metrics=[common.object.count_metric], + bucketAggs=[ + { + id: 'video', + field: common.field.video_id, + type: 'terms', + settings: { + min_doc_count: '1', + size: '0', + }, + }, + ], + timeField='timestamp' + ) + ), + gridPos={ x: 6, y: 0, w: 6, h: 6 } +) +.addPanel( + graphPanel.new( + title='Statements by ${STATEMENTS_INTERVAL}', + description=||| + Number of statements by ${STATEMENTS_INTERVAL}. + The interval is controlled by the `Statements interval` variable + |||, + datasource=common.lrs, + bars=true, + lines=false, + ).addTarget( + elasticsearch.target( + datasource=common.lrs, + query=common.query.school_course_session, + metrics=[common.object.count_metric], + bucketAggs=[common.object.date_histogram('$STATEMENTS_INTERVAL')], + timeField='timestamp' + ) + ), + gridPos={ x: 12, y: 0, w: 12, h: 6 } +) +.addPanel( + { + title: 'Video length', + description: ||| + The distribution of the video durations in seconds. + On the Y-axis is the video count and on the X-axis the duration. + |||, + datasource: common.lrs, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + displayName: 'Video count', + mappings: [], + unit: 'none', + }, + overrides: [], + }, + id: 6, + options: { + bucketOffset: 0, + combine: false, + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + }, + pluginVersion: '8.0.3', + targets: [ + { + alias: '', + bucketAggs: [ + { + field: common.field.video_id, + id: '3', + settings: { + min_doc_count: '0', + order: 'desc', + orderBy: '1', + size: '0', + }, + type: 'terms', + }, + ], + metrics: [ + { + field: common.field.result_extensions_length, + id: '1', + type: 'max', + }, + ], + query: common.query.school_course_session, + refId: 'A', + timeField: 'timestamp', + }, + ], + timeFrom: null, + timeShift: null, + transformations: [], + type: 'histogram', + }, + gridPos={ x: 0, y: 6, w: 6, h: 9 } +) +.addPanel( + { + title: 'Completion threshold by video', + description: ||| + The distribution of the completion threshold by video. + On the Y-axis is the number of videos and on the X-axis the threshold interval. + |||, + datasource: common.lrs, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + displayName: 'Video count', + mappings: [], + unit: 'none', + }, + overrides: [], + }, + id: 10, + options: { + bucketOffset: 0, + combine: false, + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + }, + pluginVersion: '8.0.3', + targets: [ + { + alias: '', + bucketAggs: [ + { + field: 'object.id.keyword', + id: '4', + settings: { + min_doc_count: '1', + order: 'desc', + orderBy: '1', + size: '0', + }, + type: 'terms', + }, + ], + metrics: [ + { + field: common.field.context_extensions_completion_threshold, + id: '1', + settings: {}, + type: 'max', + }, + ], + query: '%(course_query)s AND verb.id:"%(completed)s"' % { + course_query: common.query.school_course_session, + completed: common.value.verb_id_completed, + }, + refId: 'A', + timeField: 'timestamp', + }, + ], + type: 'histogram', + }, + gridPos={ x: 6, y: 6, w: 6, h: 9 } +)